Compare commits

...
132 Commits
Author SHA1 Message Date
zooqueen ba571999a2 the Discord invite on the deployed console is dead — recover the fix
Hanzo CI/CD / cicd (push) Successful in 3m13s
CI/CD / cicd (push) Successful in 3m13s
Four places pointed at discord.gg/hanzo. Discord's API answers "Unknown
Invite" for that code, so every Community / Join Discord control on the
deployed console led nowhere. discord.gg/CJCyAsm9Vr resolves to the real Hanzo
guild; verified both against https://discord.com/api/v10/invites/<code> rather
than by following the redirect, which returns 301 for a dead invite exactly as
it does for a live one.

Recovered from origin/main 03ed5663 ("Point every Discord invite at the current
Hanzo server"), stranded by the two-lineage split like the static image before
it — the fix existed and had simply never reached the branch that ships.

Also takes bff3be33's comment rename, EVENT_INGEST_KEY -> PUBLISHABLE_KEY: one
line, and it is the "do not fix this by reading NEXT_PUBLIC_…" warning, so a
stale variable name there points the next reader at an env var that no longer
exists.

The third stranded commit (10be3234, "topbar: the theme toggle comes from the
package, not a deleted file") is deliberately NOT taken. This lineage solved it
differently and better — the toggle left the topbar entirely and lives in the
account menu, so there is no import to repoint and no local ThemeToggle to
delete. Stranded is not the same as missing.
2026-08-05 21:18:25 -07:00
zooqueen 594314d351 console: the servable static image, recovered from the other lineage
Hanzo CI/CD / cicd (push) Successful in 3m8s
CI/CD / cicd (push) Successful in 3m9s
Cherry-picked in substance from origin/main 72870474 ("a servable static
image, so a frontend change needs no cloud release", 2026-08-04) and a4df740e
("one image, and the console is what it is", 2026-08-05), by hanzo-dev. Neither
had ever reached the DEPLOYED lineage, because this repo carries two histories
with NO common ancestor — 1643 commits on forge, 1549 on origin, and no merge
base at all.

The work was stranded, not superseded, and it is worth recovering because it
fixes something that cost two production outages TODAY. console.hanzo.ai is
answered by the cloud binary, which go:embeds the bundle, so every console
change needs a cloud image, a universe pin and a cloud roll — and cloud runs
`strategy: Recreate`, which makes each one a ~2-minute total API outage. Two
console fixes this afternoon cost exactly that, twice.

Dockerfile now builds the SPA export behind hanzoai/static and serves itself on
3000 — which is precisely the port the standalone `console` Deployment already
declares and has been waiting for. Dockerfile.embed is BYTE-IDENTICAL between
the two lineages and is untouched here, so the path that currently serves
console.hanzo.ai does not move. This adds a capability; it changes no route.

Flipping console.hanzo.ai from `service: cloud` to `service: console` in
universe hanzo-domains.yaml is the follow-up that actually collects the benefit,
and it is deliberately NOT in this commit: it changes how the console is served
and belongs to a decision, not to a file recovery.

Reconciling by SUBJECT rather than by hash is what made this tractable — 875 of
~890 subjects are shared, so the split is a re-import, not divergent work. Only
8 subjects exist solely on origin and 20 solely on forge, and of origin's 8, two
are git stash entries and one is the same fix carrying a "(#1)" PR suffix.
2026-08-05 21:16:35 -07:00
zooqueen c709b1f96a playground: every example suggests a model the gateway serves
Hanzo CI/CD / cicd (push) Successful in 4m55s
CI/CD / cicd (push) Successful in 5m2s
All six starters chipped `zen-omni` or `zen-coder`. The gateway carries neither
and never did — zen's text models are `zen5`, `zen5-mini`, `zen5-flash`,
`zen5-coder`, `zen5-pro`, while `zen-<noun>` names a modality (embedding, image,
video, rerank, voice, vl, guard).

Nothing threw, which is why it survived: applying a starter falls back to the
currently-selected model when the suggestion is absent from the live catalog
(`models.byId.has(...)`), so the card simply advertised a model that could never
be the one that ran. The model-id placeholder gave the same dead example.

`examples.test.ts` tests the SHAPE, not a list of ids — a hardcoded catalog would
rot exactly the way the suggestions did — so a modality SKU or a retired id
cannot come back. Same defect the agent builder's `defaultModel` had, where the
consequence was worse: it silently selected `zen-embedding`.
2026-08-05 19:41:42 -07:00
zooqueen 54d6ce9959 agents: one door to the builder — New Agent goes to the quickstart, the side-pane form is deleted
Hanzo CI/CD / cicd (push) Successful in 7m12s
CI/CD / cicd (push) Successful in 8m57s
2026-08-05 19:04:59 -07:00
zooqueen 8a7ae9be23 test(quickstart): the template card takes focus, rings, and Enter picks it
Hanzo CI/CD / cicd (push) Successful in 7m37s
CI/CD / cicd (push) Successful in 8m0s
2026-08-05 18:57:02 -07:00
zooqueen 19cebcb92a docs: the quickstart, the tool plane that was already live, and the rail that stopped drilling
Hanzo CI/CD / cicd (push) Successful in 5m48s
CI/CD / cicd (push) Successful in 6m27s
2026-08-05 18:55:50 -07:00
zooqueen 8b0a96c952 release: 8.5.61 — the quickstart and the rail that expands in place
CI/CD / cicd (push) Successful in 8m15s
Hanzo CI/CD / cicd (push) Successful in 6m43s
2026-08-05 18:53:25 -07:00
zooqueen ad9789b072 agents: a quickstart whose every step is a real call, and a rail that expands in place
CI/CD / cicd (push) Successful in 7m27s
Hanzo CI/CD / cicd (push) Successful in 7m4s
The builder was a form in a side pane. It is now reachable the way someone with
no agents actually starts: describe what you want, or take a template, then
configure, run and integrate. Four steps, and each is an endpoint —
`/v1/chat/completions` drafts the spec, `POST /v1/agents` creates it, `POST
/v1/agents/:ref/run` runs it, and the last step prints the request that just
worked. A step whose loader is absent says so; none of them draws a checkmark it
did not earn.

Templates are presets, never promises: every field one carries is something the
create body already expresses, and a test pins that. None names a tool, because
tools are per-org — a hardcoded `web.search` would fail at the agent's FIRST
invocation rather than here. The real ones come from `/v1/tools`, which has been
bound and serving all along while the loader's comment claimed no such endpoint
existed; it is wired now, and honest when an org has activated nothing. The
proxy admits discovery and refuses `/v1/tools/call` — running a tool belongs to
whatever runs an agent, not to a browser tab.

Two things were quietly wrong and are fixed here:

`defaultModel` named `zen-omni`, which the live catalog does not carry. So the
exact-match arm never fired and the fallback ran instead — `^zen[-.]` over a
sorted catalog, which selects `zen-embedding`. Every agent created without
touching the model field was pointed at an embeddings SKU that cannot hold a
conversation. The family test is `zen5…` now, because zen's naming splits
cleanly: `zen5*` is text, `zen-<noun>` is a modality. The placeholders were
advertising the same dead id and two invented tool names; both now say things
that exist.

The rail no longer drills. Clicking a product used to swap the whole sidebar for
that product's sub-nav behind "Back to all products"; the options were identical
either way, and what the drill took was every OTHER product — precisely what you
need when the reason you opened the rail was to go somewhere else. Sub-pages
expand beneath their own row instead. The label navigates, the chevron only
opens and closes, and that choice persists; the product you are in is open
unless you closed it. A pinned product appears twice, so exactly one copy owns
the sub-list — otherwise it is two navs painting at once, which is the thing
this rail exists to avoid.

The level-2 e2e now asserts the catalog is still on screen, which is the
invariant the drill could never have satisfied.
2026-08-05 18:51:48 -07:00
zooqueen 7e4e052694 we launch with chat, the builder and the console — the rest is beta
Hanzo CI/CD / cicd (push) Failing after 3m5s
CI/CD / cicd (push) Failing after 3m5s
LAUNCH_PRODUCTS is an allow-list of what a new signup sees tonight: the
console home, the AI plane those two products run on (chat, models,
playground, API keys, usage, logs), the money surfaces, org and account,
and the beta door itself. Everything else in the catalog — the whole
cloud and the app suite — is present, routable, and invisible until an
org holds the beta flag; a superadmin always sees it all.

Allow-list ON PURPOSE, replacing the 21 per-entry stamps: a product
added to the catalog tomorrow is hidden the day it lands, rather than
leaking onto a customer's first screen because nobody remembered to
stamp it. A test pins exactly that default.  survives as
the force-dark override for a launch surface that is not ready.
2026-08-05 16:07:39 -07:00
zooqueen 3cbaf7beb6 the tour card renders where it can be read
Hanzo CI/CD / cicd (push) Successful in 5m29s
CI/CD / cicd (push) Successful in 5m29s
Step one of every guide tour rendered clipped off the right edge: the
checklist walk hardcoded placement right, a checklist row spans the
whole card, and right of the row IS the viewport edge. Rows place
BELOW now, and the engine stops trusting placements at all: a side
with no room flips to the opposite side and falls through to
below/above, every axis clamps into the viewport, the anchor lookup
takes the first VISIBLE match (a hidden twin's zero box stranded the
spotlight), a scrolled-away target is brought back before measuring,
and an anchor bigger than most of the viewport is a container, not a
target — those center. No step, whatever its author assumed, can
render off-screen again.
2026-08-05 15:52:41 -07:00
zooqueen 298accf659 the apps are a beta, and search is discovery
Hanzo CI/CD / cicd (push) Successful in 7m4s
CI/CD / cicd (push) Successful in 7m4s
Every customer Apps surface (21 of them — CRM, Cap Table, Company,
Marketing, Ads, Social, and the rest) now carries beta: true and hides
from the rail, the pins, the palette, the All-products panel and search
until the caller's ORG holds the apps beta on the enablement plane
(kind feature, id apps) — the same self-service opt-in the Beta
features module already manages, scoped server-side. One predicate
(filterBeta, beside filterEntitled), fail-closed default, superadmins
always see, and Beta features itself is never behind its own flag —
the door to opt in must stay visible.

And the sidebar's product filter searches ALL of Hanzo: a typed query
opens the entitlement scope to the whole catalog — the point of
searching is finding what you do not have yet — while the admin and
beta gates keep holding. The resting rail stays scoped to the org's
enabled set, and the palette's typed search follows the same rule.
2026-08-05 15:48:35 -07:00
zooqueen 12d061f579 the org's own logo is the mark, and the mark row stops repeating it
Hanzo CI/CD / cicd (push) Successful in 4m50s
CI/CD / cicd (push) Successful in 4m50s
Settings' logo field gains Upload: the file becomes a compact data URL
in the same field (SVG verbatim, rasters downscaled to 64px on a
canvas, a cap refusing anything that would ride every IAM org read as
dead weight) — one value, one save path, one preview. The context
switcher renders that logo in the slot the org name held, height-capped
to the row, keeping the project beside it and the full text in the
aria-label. And the expanded rail drops its separate brand row: with
the switcher carrying the org's identity first, the H above it said
the same thing twice. The collapsed icon rail keeps its mark — there
is no switcher to carry the identity there.
2026-08-05 13:52:33 -07:00
zooqueen ff87cdbd98 playground: the response answers under the tabs
Hanzo CI/CD / cicd (push) Successful in 5m5s
CI/CD / cicd (push) Successful in 5m5s
The Response panel moves from a 1/3 right rail to a full-width row
directly under the surface tabs — the answer is the first thing on
every screen size, and the builder row follows. A YStack's order IS
the vertical order at every width, and dropping the rail's 320px
minimum from the wrap row removes the one squeeze it had; the builder
row keeps its proven phone-safe wrap. A render spec pins the geometry
(tabs, then Response, then composer; no horizontal overflow at 390
through 1920), gated on the fixture server like its siblings.
2026-08-05 13:26:04 -07:00
zooqueen 0f6cec3315 playground: offer only what routes, gate frontier prices behind a plan
Hanzo CI/CD / cicd (push) Successful in 5m1s
CI/CD / cicd (push) Successful in 5m6s
Picking Claude Haiku 4.5 errored with 'model anthropic/claude-haiku-4.5
is not available' — the bundle's openrouter spelling reached the run
while the gateway routes the bare id. The catalog now resolves each
row's ROUTING id against the live set by tail alias, so the picker
submits exactly what the gateway serves and haiku 4.5 simply works;
rows live under no spelling stop being offered at all (outage degrades
to the full catalog so the picker never renders empty).

Premium is derived where the bundle omits it — input >= $5/Mtok or
output >= $25/Mtok, the Opus class at both its price points, Sonnet
and Haiku under both bars — and an org without a standing subscription
is not offered premium rows (usePlanGate over the org's own scoped
/billing proxy, failing open on billing outage). The gateway's 402
remains the enforcement point; the picker stops promising what it
would refuse. Seeding draws from the same offered pool, so a free org
never boots onto a premium default.
2026-08-05 11:19:05 -07:00
zooqueen 4d9aabb7bd release: 8.5.50 — the manifest says what the registry already carries
Hanzo CI/CD / cicd (push) Successful in 5m49s
CI/CD / cicd (push) Successful in 5m49s
The version in package.json had sat at 8.5.35 while CI published through
8.5.48 (the tag hanzoai/cloud pins today): a main push derives its number as
max(declared, published) + a patch, so the registry floor carried the series
alone and the manifest drifted fifteen patches behind it.

That matters for a hand-cut tag, because a tag build publishes the tag
VERBATIM with no monotonic check — cutting the next number after the last git
tag would have republished a semver that already exists, over different bytes,
and orphaned anything pinned to it. Saying the number here puts the human's
say-so back above the floor and makes the next release the one nobody is
holding.
2026-08-05 01:38:35 -07:00
zooqueen 30acbc52d0 assistant: the send carries a credential, and the card stops lying about why
Hanzo CI/CD / cicd (push) Successful in 7m52s
CI/CD / cicd (push) Successful in 7m55s
Four defects, all measured on the live console with wire captures.

The completions request carried NO Authorization header at all. `streamChat`
reached for a bare `fetch` because a stream cannot go through the parsing
helpers that consume the body — and in doing so it left the one file where
identity is attached, so every send answered 401 while the identical
non-streaming call succeeded. So the client grows the door that was missing,
`restStream`: same `authedFetch`, same `baseHeaders`, raw `Response` back. The
gateway stream, text-to-speech and the guide's SSE step-runner all take it, and
a test drives the real path and reads the Bearer off the wire.

Preferences saved to `/v1/ai/preferences`, which is the AI gateway's casibase
handler and authenticates on a casibase SESSION — no Bearer can satisfy it, so
every save was refused "Please sign in first" while the user was signed in. They
now ride `/v1/prefs`, the per-user plane on cloud, which reads exactly the
credential the console already sends. That plane also SERVES a read, which the
old one never did: preferences were recovered from a snapshot of them carried in
the identity token, and a merge had to guess from two timestamps which side was
newer. Reading the document removes the guess, and the ordering machinery with
it.

The 401 card said "Your session expired" to people whose session was live. The
shared classifier can only see the status; whether this session is live is a
fact only the app holds, so the assistant decides it — a refusal with an
unexpired token reads as a surface that refused a signed-in caller, and "sign in
again" is kept for when it is true.

On a desktop the composer could not be clicked. Docking opened the sheet AND the
column and hid one with CSS, but the sheet is a modal dialog and hiding its
content does not stop it being modal: it covered the viewport, the column
painted inert beneath it, and every click landed on the dialog. The two shapes
now meet in one fact — the dock choice, a viewport that can hold a column, and a
page that is not already a composer — and exactly one of them mounts.

Also: the cross-app launcher sat in the lg-only topbar group, so on a phone the
grid of other Hanzo apps was unreachable. It moves to the account drawer, whose
trigger is on every viewport — one launcher, reachable everywhere.
2026-08-05 01:33:28 -07:00
hanzo-dev c1a8c8495d the tenant is the org, and "Team" is that org's members — say both
Hanzo CI/CD / cicd (push) Successful in 8m35s
CI/CD / cicd (push) Successful in 8m36s
Nothing here was ever a team. The referral, store, affiliate and author copy
called a customer organization "a team" ("when a new team signs up", "every
time a team runs your project"). The onboarding step called the tenant "your
workspace" in its title and "your team" in its blurb, while the org picker one
control away said "organization" — three words for one thing.

And Settings > Team was not a team either: its own description reads
"Organization members and roles", its index label already said "Members", and
the sibling button already said "Manage members". Only the nav label held out,
sitting in brand-scope as "Team, organization, and profile" — as if the two
were different things.

So: the tenant is an organization everywhere a human reads it, and the page
listing the people in it is Members.

Left alone: the `/team` route id (it is a link people already have), the `team`
plan tier, the helpdesk's HD Team doctype, and the tracker's real Team entity
whose boards are keyed ENG-1 — none of those are tenancy. "Teammate" and
"invite your team" stay too; those are people.
2026-08-05 01:17:31 -07:00
zooqueen a6bc25943f lists: numbered pages, and one stale claim corrected
Hanzo CI/CD / cicd (push) Successful in 4m51s
CI/CD / cicd (push) Successful in 4m51s
The observability footer offered Prev and Next and nothing else, so a reader 40
pages into a trace list reached page 1 by pressing Prev forty times. Pagination
from @hanzo/ui/product gives numbered jumps with an ellipsis. What stays in
Pager is the sentence only this surface can write — the row range — because only
it knows the shape of the meta its endpoints return.

ModelSelector's header said the package's selector was "the shadcn/Tailwind
build" and that the console, running Tamagui, could not use it. That stopped
being true: at 8.0.56 @hanzo/ui/models imports @hanzo/gui and
@hanzogui/lucide-icons-2, with no Radix and no class strings. Two real things
still block the swap and the comment now names them — the entry shape
(CatalogEntry vs ModelCatalogEntry, which decides family grouping) and the row
logos (this one draws curated brand marks through ui/ProviderLogo; the package's
draws none). A stale reason is worse than no reason: it retires a question that
is actually still open.

playground/ModelSelect had its import placed above 'use client' by the sweep
that moved it onto the package. Next reads the directive only as the first
statement, so the file had quietly stopped being a client module.
2026-08-04 23:33:49 -07:00
zooqueen 70b99204ac secrets: a masked field that is actually masked, and one copy button
`secure` on a gui Input is React Native's spelling, and the WEB build drops it:
`secureTextEntry` becomes type="password" only in @hanzogui/core's native.cjs —
grep the esm/cjs builds and the prop is not there at all. So every field in this
console that asked to be masked rendered its value in plain text, and the prop
made it look handled.

Five of them were secrets:

  AdminModule       a user's initial password
  KmsModule         a KMS secret value
  ConnectionsModule a provider API key
  AccountsTab       an AI provider key
  platform-apps     an app env secret — on a panel whose own caption promises
                    "never stored or shown in plaintext"

All five now use SecretInput from @hanzo/ui/product, which sets BOTH spellings
(`masked()` — neither alone is safe) and adds a deliberate reveal. ApiKeysModule
joins them: it printed a freshly minted key as bare selectable monospace, and
SecretInput's read-only mode copies while still masked, which removes the reason
to unmask at all.

The copy control was written seven times — sentry, code, git, webhooks,
Kubernetes, api-keys, playground, verify — each with its own confirmation window
(1200, 1400, 1500, 2000ms) and its own error behavior, one of which logged the
clipboard failure with the value in it. They are one import now. git/parts
re-exports it so its four views keep a single import site, and CopyRef stays as
the wrapper that knows how to spell a `repo file:line` reference.

The shared control also reports what was copied by LENGTH, never by content.

Typecheck clean; 3210 passing, 8 skipped.
2026-08-04 23:30:50 -07:00
zooqueen b88389e91c ui: delete the shadow fork, and reach for the package that replaced it
src/components/ui/ was a ~30-file copy of components that were hoisted OUT of
this console into @hanzo/ui/product (hanzoai/ui#36). The hoist landed; the copy
never left. Both halves kept getting edits, so the console has been shipping the
half nobody else could see.

Twenty modules go back to the package, 278 call sites now import
'@hanzo/ui/product', and 2,311 lines leave the tree. Everything the package
gained while the copy sat here arrives with them — chiefly the interaction
instrumentation: DataTable, PrimaryButton, ConfirmDelete, EmptyState, Segmented
and SearchInput now report what a user did through @hanzogui/telemetry, which
console already depends on, with no wiring and no provider to mount.

BackendStateCard needs two effects a presentational layer cannot have — sign in
again (401) and add credits (402). The package asks its host for them, so
src/entry/host.tsx answers once at the dashboard root and all 106 cards below
render the right affordance unchanged. One answer, not 106.

The package's components name their CSS classes without the hz- prefix
(`skeleton`, `row`, `tnum`, `fade-up`, `drag`), so app/layout.tsx now imports
@hanzo/ui/styles/motion.css. Without it a DataTable's skeleton, row hover and
tabular figures render unstyled — the classes simply would not match. Console's
own hz- twins stay in globals.css; 250+ call sites in console markup still name
them.

Four things did NOT come back, because the package version is behind the copy
rather than ahead of it, and swapping them would ship a visible regression:

  ProviderLogo — draws curated per-family brand marks (BRAND_MARK, inline
    Slack/GitHub, brand-colored monograms). The package knows two providers and
    renders initials for everything else.
  ProductIcon — picks its glyph color with contrastText(). The package hardcodes
    #ffffff, which is unreadable on 3 of console's 19 product accents and
    invisible on #FAFAFA.
  Donut, Metric, Charts — read console's theme: var(--color4/9/12) rather than
    hardcoded dark hex, and the monochrome RAMP rather than the package's
    eight-color SERIES. Swapping repaints every chart in the console.
  SlideOver, Toast — take their layer from lib/z.ts. The package hardcodes
    zIndex 1000 and 100000, which are two of the exact literals that ladder was
    introduced to eliminate.

Field and Filters shrink to the part that is genuinely console's:
FieldOptionSelect (a value/label picker the package has no twin for) and the
Filters bar (it takes a List, so it is app-coupled by construction). color.ts
keeps only tileRadius, which encodes console's radius scale.

ThemeToggle and HanzoMark are deleted outright — nothing imported either; the
mark everything actually uses is ui/Loader's BrandMark.

Reorder.test.ts and combobox/filter.test.ts go with the code they tested: 3222
tests to 3210. Their assertions cannot follow the logic upstream, because
@hanzo/ui/product publishes no node-resolvable subpath for its pure functions —
importing the barrel drags @hanzogui/next-theme into vitest and fails on
next/script. A consumer therefore cannot guard the contract it now depends on.

Typecheck clean; 3210 passing, 8 skipped.
2026-08-04 23:23:59 -07:00
zooqueen d78a71e50d deps: @hanzo/ui 8.0.56 — the release that carries the product set
The console's own src/components/ui/ is a copy of components that were hoisted
into @hanzo/ui/product (hanzoai/ui#36) and never deleted here. 8.0.56 is the
release where every one of them lands published, so the copy has somewhere to
go. Typecheck clean and 3222 tests green on the bump alone — nothing in this
tree reaches an API that moved between 8.0.38 and 8.0.56.
2026-08-04 22:42:35 -07:00
zooqueen f8d83250f7 models: reach first, and the chrome carries only navigation
Hanzo CI/CD / cicd (push) Successful in 6m0s
CI/CD / cicd (push) Successful in 6m0s
The Models page listed every family in order but gave the user no way back to
the models they actually use: every visit started at the top of a long list.
A Recent + Suggested chip strip now sits above the families — recents are the
user's own trail (recorded where a model is genuinely exercised: a chat turn
sent, a catalog detail opened; account-persisted, so it follows them across
devices), suggestions are one live rung per pinned family, which puts the
house default first by construction. Both hide while a search owns the page,
and neither ever shows a fabricated chip.

The topbar slims to what navigation needs: status, docs, the cross-app
launcher, and the account drawer trigger — now on every viewport. The network
chip leaves the bar: production is the default environment, so switching is a
deliberate act you open the drawer for, not chrome you wear. The theme toggle
leaves too — the account menu already carries theme, and two controls for one
setting is one too many. Alerts stay at /alerts and in the drawer.

Chat's stale copy caught up with the Enso default: the surface no longer
greets you with the name of a family it stopped defaulting to.
2026-08-04 21:48:19 -07:00
hanzo-devandzooqueen a7a7fc9ef6 console: the cross-app launcher it never had
Console had no way to reach another Hanzo surface. The two "switchers" in its
topbar are about where you are INSIDE console — ContextSwitcher picks the org
and project, ScopeSwitcher picks the network — and the LayoutGrid in the sidebar
lists console's own products. A fullscreen launcher existed once and was removed
as a duplicate of that product filter, which it was; nothing replaced the part
it was actually for.

@hanzogui/shell has shipped HanzoAppLauncher the whole time, and chat already
wears it. It is inline-styled and React-only, so it drops into a Tamagui topbar
untouched — no wrapper, no theme bridge. quickSwitchKey={false} because
CommandPalette.tsx:806 already binds ⌘K; the shell's prop doc names Console as
the reason that opt-out exists, so this integration was designed for and then
never landed.

Also takes shell to 8.1.1, where the panel is portalled out of its host's
stacking context. This topbar does not clip it today, but the version that
cannot be clipped by a container the component never sees is the one to pin.
2026-08-04 21:48:19 -07:00
hanzo-devandzooqueen 7c6305c455 toast: memoize the context value so raising a toast cannot raise another
The provider built its context value fresh on every render and passed it
straight to Provider. Its own state changes on every toast, so each toast
handed every useToast() consumer a new identity. An effect that both
depends on the toast api and raises a toast therefore re-triggered itself:
raising one rendered the provider, which handed the effect a new api,
which raised another. The OAuth return did exactly that and stacked twelve
identical cards down the viewport.

Stripping the query params could not have stopped it. router.replace is
asynchronous, so the params are still readable on every render in between
-- and the effect calls load(), which causes exactly those renders. The
integrations return now latches once per mount; stripping the params
covers the NEXT mount, which is the case it can actually address.

The callbacks were already stable, so memoizing the object is the whole
fix, and it makes depending on the toast api safe everywhere, not just
here.

Proven by render, since no unit test can see a render loop: reverted, the
new spec counts 12 toasts; fixed, it counts 1.
2026-08-04 21:48:19 -07:00
zooqueen 15da85d0ac the assistant answers as Enso, and asks for anything
The console preselected zen5-flash and greeted you with 'Message
zen5-flash…' — the machine's model id where an invitation belongs, and
a stand-in family where Hanzo's own belongs. Enso left limited preview,
so the house family is what an unchosen caller should get: DEFAULT_MODEL
is enso-flash, the family's free rung, which keeps the property the old
default was chosen for (a trial or welcome balance still answers on the
first message). The placeholder now says what the box is for.

The preselect's fallback follows: the house family, then the first
model discovery offers.
2026-08-04 21:48:19 -07:00
hanzo-dev d20f0859f2 fix(entry): an anonymous console visitor starts the IAM hop, not a second landing
Hanzo CI/CD / cicd (push) Successful in 4m45s
CI/CD / cicd (push) Successful in 4m45s
Clicking "Sign in" on cloud.hanzo.ai appeared to do nothing. It was not a redirect
loop and nothing returned an error — every hop was HTTP 200, which is why the page
"looked fine".

console.hanzo.ai/ served a SECOND copy of the Hanzo Cloud marketing page, wearing the
byte-identical @hanzogui/shell header. So the journey was:

  cloud.hanzo.ai  [Sign in] -> console.hanzo.ai/   (same header, same "Sign in")
  console.hanzo.ai [Sign in] -> /signin            (one button, nothing else)
  /signin [Log in with Hanzo Cloud] -> hanzo.id

Three clicks, and the first landed on a page indistinguishable from the one it left.
That reads as a re-render, and users stopped there.

The console is the APPLICATION; the marketing face of Hanzo Cloud is cloud.hanzo.ai.
Serving a third copy of it on the app host is what created the illusion. So `/` is no
longer a special surface: everything except the two auth routes is `guarded`, and a
definitively-anonymous visitor STARTS the authorize hop instead of being parked on an
interstitial that only asks "did you mean it?".

  cloud.hanzo.ai [Sign in] -> hanzo.id

`startReauth()` is reused rather than `signinRedirect()`, so a deep link (/models)
returns to /models after login instead of dumping the user on the home.

SIGN-OUT IS PRESERVED, the one hazard here. Sign-out lands on /signin, which keeps its
button and never auto-authorizes -- IAM may still hold its own session, so authorizing
there would sign the user straight back in and make signing out impossible. A callback
failure lands on the same surface, so a broken hop cannot loop.

Removes PublicLanding + landing-surface, now unreachable (-269 lines net).

Tests: 258 files / 3211 assertions pass; tsc --noEmit clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 20:12:20 -07:00
hanzo-dev 53375e44ed test: make the rescued gui-8 guard true on this lineage
Hanzo CI/CD / cicd (push) Successful in 5m3s
CI/CD / cicd (push) Successful in 5m4s
The rescued gui8-props guard lands red here because origin fixed the same
line-height bug a different way and grew a shell the guard's fixtures predate.
Three corrections, none of them a weakened rule:

- gui8-props: blank out whole comment SPANS, not lines whose opener is `//`.
  The module already says a doc comment must be free to name the bug it
  documents; the fix comments at the call sites are multi-line `{/* … */}` JSX
  blocks, so the continuation lines were still scanned and PitchHero's own
  description of the bug counted as the bug.
- Charts: `lineHeight: 1.35` -> `'1.35'`. This one is a plain `<div>`, where
  React already emits it unitless, so the CSS is unchanged — but the string form
  is what the rule asks for and stays correct if it ever becomes a gui component.
- shell.test: `tracker` is a shell now (the rescued tracker pick), so the
  descriptor table's expected ids include it.

tsc --noEmit: 0 errors. vitest: 3217 passed, 8 skipped, 0 failed.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 6ca6cc8fe1)
2026-08-04 18:59:00 -07:00
hanzo-dev 87abc48add fix(gui): the gui 8 props that silently rendered nothing
The 8.x dependency convergence was already done and building. What was left is
the half it cannot catch: @hanzo/gui accepts any prop and drops the ones it does
not recognise, so a gui 7 spelling type-checks, builds, ships, and does nothing.

Asked the renderer instead of the type-checker (scripts/gui-prop-probe.mjs
renders a prop and reads the host element and emitted class back out), which
settled the open `tag` vs `render` question the interrupted session left and
found four live defects:

  tag="a"                -> <div tag="a">        an inert link
  style lineHeight: 1.1  -> line-height: 1.1px   a ratio is not a length

- CloudflareModule's pages.dev/workers.dev URL chip and every ContactModule
  channel card (mailto included) were <div>s. Both now render="a".
- PublicLanding's hero title, PitchHero's headline and the CodeSamples block
  shipped line-height 1.1px/1.12px/1.6px — a wrapped title, and every line of a
  code sample, drawn on one baseline. gui appends px to a bare number in `style`
  as much as in a prop, so the ratio is now spelled as a string.

Because the type system provably cannot gate this class, the gate is the source
text: src/lib/gui8-props.ts holds the four verified rules and its suite runs them
over every file that imports gui (335 of them). Scoping by import is what keeps
it precise — a `{ tag: 'v1' }` image tag in a pure-logic module is out of scope
by construction, as are the rule module and its own fixtures.

Also: typescript stays on 5.x, and that is correct rather than a shortfall.
typescript@7.0.2 is genuinely the native Go compiler (its tsc is a statically
linked ELF from typescript-go/cmd/tsgo), but it ships only
{version, versionMajorMinor} on the main entry, while `next build` calls
ts.parseJsonConfigFileContent / ts.JsxEmit / ts.ModuleKind /
ts.ModuleResolutionKind — all undefined there. TS7 breaks Next exactly the way it
breaks tsup, for the same reason. @typescript/native-preview (7.0.0-dev, behind
stable) stays removed.

Verified: next build ✓ compiled successfully in 25.8s, 20/20 pages;
tsc --noEmit exit 0; vitest 2903 passed / 8 skipped (the pre-existing enso-bench
parity self-skip); build:embed ✓ static export ready, 30 handlers restored.
Zero tailwind/radix/shadcn, not even transitively.

(cherry picked from commit c9a4d92554fa8b8274801218a2c14a0044cf17bf)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 19e6d2e250)
2026-08-04 18:59:00 -07:00
hanzo-dev 8153606ac8 wip: preserve agent work interrupted by session limit
(cherry picked from commit bac7cc5bf68eb03973ab0bec5d28fcc23b2a2ee8)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit e077a2285a)
2026-08-04 18:59:00 -07:00
hanzo-dev c4ac4eff3f wip: preserve agent work interrupted by session limit
(cherry picked from commit c256eb39aab2280121bfec766fdea3ea92f71091)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 775d9ab840)
2026-08-04 18:59:00 -07:00
hanzo-dev 922ba7d676 wip: preserve in-flight telemetry + event bump before 8.x convergence
(cherry picked from commit 0045bac1727d1251991f1106b8f2bccb1acb34ac)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 909388da5e)
2026-08-04 18:59:00 -07:00
hanzo-dev 15b8cff948 refactor(commerce): render the shared CommerceResource, not a second copy
@hanzo/ui/product's CommerceResource documents itself as what BOTH the console's
Store category and the standalone Commerce admin render. That was false while
this directory kept its own copy — the console imported the local one, so the two
surfaces could drift silently and the shared component's docstring lied.

The local fork is deleted and the import repointed at @hanzo/ui/product, which is
what the Commerce admin already renders. One component, two surfaces, and the
docstring is now a fact.

tsc --noEmit: exit 0, 0 errors.

(cherry picked from commit e9524b5c4588ca6664ca23c341863faadaa48141)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 4dbae2115d)
2026-08-04 18:59:00 -07:00
hanzo-dev bbff6b3a82 feat(tracker): Linear-grade standalone tracker at tracker.hanzo.ai
Rebuild the console Tracker into a SOTA, Linear-grade issue tracker over the
native cloud /v1/tracker surface, and give tracker.<brand> its own standalone
shell (the catalog chrome is stripped; the module's own views ARE the nav).

- Standalone shell: `tracker` ShellId + isTrackerHost + shellFromHost +
  PRODUCT_SHELLS.tracker (indexLabel "Issues", home "tracker"). Registry entry
  repointed to the native cloud backend with :view / :view/:sub routes and
  subpages (My Issues, Teams, Cycles, Roadmap). Host-detected exactly like
  billing./sentry./dns. — one image, one more face.
- Unified board: client-side cross-project merge (listAllIssues) → ONE filterable
  board across every team AND every mirrored GitHub repo (the App-webhook lane's
  GH mirror), with no second store. Group by status/priority/assignee/team,
  filter (status/priority/kind/source/assignee/label/team), live search,
  List <-> Board.
- Issue detail pane: one-click status, full edit, agent hand-off (assignee +
  `agent` label -> the cloud coding seam opens a linked PR), the issue<->branch
  <->PR chain (linkedPRs), git.hanzo.ai + upstream GitHub links, epic children.
- Keyboard-first: c=create, /=search, g-chord nav (g i/m/t/c/r), j/k/arrows/
  Enter/e, and — standalone only — a capture-phase Cmd-K command palette that
  never fights the console's global Cmd-K.
- Cycles (derived current iteration + progress) and Roadmap (epics + their
  ExtRef children), both from real data — honest, never fabricated.
- Richer Issue type (kind/source/repo/extRef) so GitHub-mirrored + agent-PR rows
  render distinctly; a "Sync GitHub" action triggers the org backfill.

Pure decisions decomplected to tracker/logic.ts (27 vitest green) + the shell
face test. Native @hanzo/gui v5 + React 19 — zero Svelte. Consumes the concurrent
App-webhook lane's GitHub mirror through the existing endpoints (no backend
duplication; the tracker data model + views + agent flow are owned here).

(cherry picked from commit 781db541e8476cb7e0bf1800c55f86f6d9162b50)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit 613abbabb5)
2026-08-04 18:59:00 -07:00
zeekayandClaude Fable 5 83e6eb4667 Read design tokens from the published package, not a vendored copy
Hanzo CI/CD / cicd (push) Successful in 5m16s
CI/CD / cicd (push) Successful in 5m16s
app/design/ carried a verbatim vendor of @hanzo/design's tokens, synced by hand
on 2026-07-24 because the package wasn't on npm. It is now (0.4.6), and the
vendored copy had already drifted a full border rework behind it — pure-black
ground, solid #1f1f1f borders, grey destructive, the pre-0.4.x palette the rest
of the fleet just moved off of. index.css now imports the real dependency and
the seven vendored token files are deleted; the two console-only tokens they
added (--border-card, --border-hairline) were unused. The Tamagui theme layer
in globals.css derives --colorN from the neutral ladder, which is unchanged, so
only the semantic surfaces adopt the rework — the intended unification.

tsc 0 errors, 3157 tests pass, next build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 15:53:28 -07:00
zeekayandhanzo-dev 32c88caa12 Merge branch 'lda' into mrg
Hanzo CI/CD / cicd (push) Successful in 6m35s
CI/CD / cicd (push) Successful in 6m36s
# Conflicts:
#	LICENSE

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 14:18:13 -07:00
hanzo-dev b4c71e1aac Merge branch 'fix/restore-upstream-license'
Hanzo CI/CD / cicd (push) Successful in 7m16s
CI/CD / cicd (push) Successful in 7m17s
Move vendored MIT copyright notices out of LICENSE into NOTICE, and add the
full MIT permission text alongside them. LICENSE is reserved for this
project's own BSD-3-Clause grant.
2026-08-04 12:16:05 -07:00
hanzo-dev c8295794fa Merge remote-tracking branch 'origin/ux/console-fab-apps-nav' into try-ux
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 12:08:51 -07:00
hanzo-dev 40da2fc17d o11y metrics: follow the read to its own name
cloud's per-product RED window and upstream o11y's metric-NAME CATALOG were both
answering GET /v1/o11y/metrics. Two different questions at one address, which is
why cloud had to suppress the module's real read to boot at all. cloud's moves to
/v1/o11y/product/metrics — the honest name for "one product's requests, error
rate and p95" — and the bare name goes back to the catalog it describes.

This is the read behind the platform-apps drawer, so it must land WITH the cloud
change (hanzoai/cloud fix/o11y-route-ownership): before it, this path 404s;
after it, the old path answers the catalog's shape instead of RED numbers.

`o11y` is still the allow-listed head in proxy-allow.ts, so the BFF is unchanged.
vitest 6/6 on the touched suite. tsc reports 3 errors in PublicLanding.tsx and
landing-surface.ts for a missing `@hanzogui/shell` — pre-existing here, present
on origin/main and untouched by this change, which edits only string literals.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 11:38:17 -07:00
hanzo-dev d09d3cae1b the assistant has one home, and the app directory is one you can walk
The assistant's way in was the only shape of it this module did not own: two
small buttons in the topbar, wedged between the search box and the account
chrome, putting it in a third place and squeezing a 390px header to five
controls. Both move into one floating control bottom-right — the same
openChat/startVoice, the same surface, in the corner it actually appears in.

All products was a directory you could not walk. Each app rendered as a plain
DIV with role=null and cursor:auto — measured, not read — so the one place the
whole catalog is browsable had exactly one live control per row, the pin. The
row opens its app now, through the shared openProduct, and closes the pane
behind it; pin stays a separate control that stops the press from bubbling.

And a pin made after sign-in was thrown away on the next reload. Preferences
are read off the identity token's claims — a snapshot taken when that token was
minted — and once a user has saved anything the token CARRIES one, so the merge
let an hour-old snapshot beat a newer write. It is now told the ordering it was
missing: the token's own iat against a stamp written only when the server
acknowledges a save. A save that never landed earns nothing, so this orders two
real writes rather than inventing durability in localStorage. There is still no
GET for the document; the smallest seam is named in preferences-core.

Five white-filled buttons competed on the home, counted by computed background
luminance. Now one: the getting-started card's active step.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 10:38:14 -07:00
hanzo-dev 0207df0759 fix(license): move vendored MIT notices out of LICENSE into NOTICE
console is NOT a fork -- its root commit is Hanzo's own and Hanzo is the
correct copyright holder. LICENSE is now a clean, canonical BSD-3-Clause
(verified against the SPDX text) naming Hanzo alone.

The seven retained MIT notices for vendored code (Tamagui, react-native-web,
Radix, Framer Motion, WorkOS et al) were attribution living in LICENSE. They
move to NOTICE, with the MIT permission notice reproduced so the obligation
travels with them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-04 09:59:29 -07:00
zooqueen a0a489986d onboarding: Continue keeps its place, and no step can strand you
Hanzo CI/CD / cicd (push) Successful in 4m41s
CI/CD / cicd (push) Successful in 4m42s
StepActions was the last child of a flex column, so its y was whatever the
step's content happened to add up to — Continue sat at a different height on
every step and a user clicking through had to re-aim each time.

It is a slot on StepShell now, above a content area with a reserved height:
one placement, decided in one place. Taller content still grows.

The workspace step also offered no way past it without naming the workspace,
which is optional — it skips now. Consent deliberately still has none:
accepting the Terms is not optional, so an affordance that skipped them would
be dishonest, and Continue stays disabled until the box is ticked.

Proven by geometry in a browser, because the JSX move is invisible to a unit
test: both shapes render the same button with the same label. Removing the
reserved height fails the spec; restoring it passes.
2026-08-04 06:57:10 -07:00
hanzo-dev b6e35ee042 legal: console is MIT OR Apache-2.0 (HIP-0137)
Hanzo-original work; BSD-3 is out of scope for hanzoai under HIP-0137.
LICENSE becomes the dual pointer, LICENSE-MIT / LICENSE-APACHE carry the
texts, and the upstream MIT copyright notices previously kept in LICENSE
are retained verbatim in LICENSE-MIT. README, LLM.md, NOTICE, Dockerfile
and package.json follow.
2026-08-04 01:28:13 -07:00
hanzo-dev 06e416365b retire the ML Pipelines (Kubeflow) product — its whole backend is gone
Hanzo CI/CD / cicd (push) Successful in 5m28s
CI/CD / cicd (push) Successful in 5m29s
The `ml-pipelines` product (label "ML Pipelines", description "Orchestrated
training and evaluation pipelines (Kubeflow)", status enabled, slug aliases
`/kubeflow` and `/mlpipelines`) read nothing but Katib and Trainer: its three
sources were GET /v1/train/health, GET /v1/train/experiments (its "Pipelines")
and GET /v1/train/jobs (its "Runs"). Those CRDs are not served by the cluster and
cloud deleted the ops, so every one of them is now a 404 and the product's only
possible state is an error card. `KubeflowApi` had no other consumer.

There is nothing to repoint it at. Per-org model-shape SEARCH — the job Katib
was installed for — is /v1/risk/search, which runs natively in the org's own
sandbox and needs no CRD.

Also removed from the `/training` BFF allow-list: `train/jobs`,
`train/experiments`, `train/health`. An allow-list entry is a declaration that a
path exists; those three no longer do. `ml/models` (kserve, live and serving) and
the eight `finetune/*` broker heads stay exactly as they were.

STILL WIRED TO THE DELETED PATHS, deliberately left for its own change:
FinetuningModule's Jobs tab, its loss chart and NewTrainingPanel still call
TrainApi.listJobs/createJob/experiments. Gutting them would delete a product;
the right fix is to repoint them at the /v1/finetune/* broker — already
allow-listed two lines below in the same proxy, and a richer surface (presets, HF
pickers, cancel, deploy-to-serving). That is a payload-contract change with its
own verification, not a line to slip into a deletion.

Verified: match-core 37/37; the full suite is 3175/3175 on this tree, which is
pristine origin/main's 3177 minus exactly the two retired alias assertions, with
the same single pre-existing social.test.ts collection failure. tsc reports SIX
FEWER error files than pristine origin/main (413 vs 419 — the local
@hanzo/gui shorthand drift this box has either way) and not one error in a file
this change touches.
2026-08-04 00:32:39 -07:00
zooqueen da268976b8 auth: a refusal is not always a failure
Hanzo CI/CD / cicd (push) Successful in 4m59s
CI/CD / cicd (push) Successful in 4m59s
The callback screen said "Sign-in failed." to everyone. Cancel a Google consent
screen and the product told you it broke; return with a session the issuer would
not reuse and it said the same thing. Both are ordinary OIDC answers, not faults,
and the words were the only thing wrong with them.

The SDK is right to report them uniformly — it validates `state` before honouring
an error branch, because /callback?error=… is a plain GET anyone can hand a
victim, so nothing downstream may DECIDE from that code. But it may READ it: the
screen now classifies the code for WORDING only, with authority left where it was.
access_denied says the sign-in was cancelled; login_required / interaction_required
/ consent_required say the session ended and lead with Sign in — the action that
actually resolves them, rather than a Retry that repeats the same refusal. Anything
unrecognized prefers the issuer's own error_description, which names a real cause
far better than a generic line ever did.

Both strings arrive from a redirect, so both are bounded before they reach a
screen whose only job is to say what happened.

This also unblocks silent SSO: a top-level prompt=none attempt returns
error=login_required to the app, and until now that landed on "Sign-in failed."
— a wrong screen for the most ordinary outcome that flow has.

Pure classifier, tested apart from the browser: 9 cases covering each class, the
issuer's own words, the unbounded-description bound, and a malformed query, which
must not throw on the one screen a person cannot navigate away from.
2026-08-03 22:21:07 -07:00
zooqueen 2271c29297 profile: a photo you can change, instead of one you can only look at
Hanzo CI/CD / cicd (push) Successful in 5m4s
CI/CD / cicd (push) Successful in 5m5s
The Profile card rendered `avatar` read-only and offered "Edit in IAM" — which
links to an IAM that cannot set one either: its only writers are federation (a
GitHub avatar_url, an OIDC picture claim) and SCIM. A user who signed up with a
password had a monogram and no way out of it.

Now the card has "Add photo" / "Change photo". It posts to cloud's new
POST /v1/avatar, which stores the image in S3 and writes the URL onto the IAM
user row, so the change is visible to every product rather than this tab —
`reload()` re-reads the session so the rest of this tab agrees too.

The photo is downscaled to 512px in the browser first. A phone original is
several MB and would be served to every viewer of every page showing that face;
512 is larger than any surface renders it. The server still caps the body — this
is the courtesy, not the guard — and a source the canvas cannot decode is sent
verbatim so the server's format check stays the authority.

postForm is the ONE upload door, added because `request` JSON-encodes its body
so a file cannot travel that way. It shares everything else: the same
authedFetch (bearer + refresh) and the same baseHeaders tenant stamp, so an
upload is org/project-scoped exactly like a read. Content-Type is deliberately
deleted rather than set — the browser must write it itself to carry the
multipart boundary.

A refusal shows the SERVER'S reason: "a profile photo must be a PNG, JPEG, GIF
or WebP image" is actionable where "Request failed" is not.
2026-08-03 18:03:31 -07:00
zooqueenandhanzo-dev 9da398459e deps: iam 0.21.6 — the SDK whose storage default survives prerender
Hanzo CI/CD / cicd (push) Successful in 4m54s
CI/CD / cicd (push) Successful in 5m24s
The previous commit dropped console's explicit `storage: sessionStorage` so the
session would be shared across tabs, and the build died on the next push:

  Error occurred prerendering page "/auth/callback"
  ReferenceError: sessionStorage is not defined

`^0.21.2` resolved to a build whose default was the bare global
`config.storage ?? sessionStorage`, evaluated in the IAM constructor. In a
browser that is merely the wrong lifetime; under Next's static export it runs in
Node, where the identifier does not exist at all, so the constructor threw and
the export exited. Passing `sessionStorage` explicitly had been masking it —
the callback page constructs the SDK at module scope, and the value it passed
was the client-side one Next never evaluates on the server.

0.21.6 resolves storage through a guarded probe (`typeof localStorage`, then an
actual write, since privacy modes expose the object and throw on setItem) and
falls back to an in-memory Storage. So prerender gets a real object, the browser
gets localStorage, and neither needs a caller to know which one it is.

The caret already permitted 0.21.6; the lockfile is what pinned 0.21.2, which is
why CI installed the broken one while a local `pnpm install` would not have.
Verified by running the export that failed: 13/13 static pages, /auth/callback
among them.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 17:18:58 -07:00
zooqueen b9d31aa305 landing: the CTA starts the sign-in, instead of asking again
Hanzo CI/CD / cicd (push) Failing after 1m44s
CI/CD / cicd (push) Failing after 1m56s
Reaching a login form took three clicks across two hosts: "Sign in" on
cloud.hanzo.ai landed here, "Sign in" here routed to /signin, and /signin's
entire content was one button that starts the authorize redirect. The middle
step asked "did you mean it?" about an answer already given one click earlier.

Both CTAs now call signinRedirect() directly. /signin is unchanged and is still
the one sign-in surface — the guarded entry sends anon visitors there, sign-out
lands there, and deep links resolve to it. It deliberately does NOT auto-redirect
on load: after an explicit sign-out IAM may still hold a session, so an automatic
authorize would sign the user straight back in and there would be no way to leave.
The button there means something; the hop to reach it did not.
2026-08-03 17:07:58 -07:00
zooqueen 1b50c31a96 auth: one token store, and every tab can see it
The console passed `window.sessionStorage` to the IAM SDK explicitly, so the
session was scoped to the tab that established it. A middle-clicked link opened
signed out. Nothing here documented that as a security posture — the comment
merely restated the SDK's default — so this drops the override and lets the SDK
own the decision, where the other surfaces inherit the same one.

The hand-rolled `memoryStorage()` goes with it. It existed only because the SDK
used to touch a bare `sessionStorage` global that is undefined under SSR; the
SDK now falls back to memory itself, so the shim has no remaining job.

Worth naming: console already owns a stronger mechanism than either Web Storage
area — `src/lib/server/session.ts`'s AEAD-sealed httpOnly `hz_session`, written
precisely so a session "cannot lapse out from under a working tab". The client
half regressed off it onto the SDK's per-tab store. This change fixes the tab
bug; moving the credential itself out of script-readable storage is the separate,
larger piece of work that cookie was built for.

`hz_return_to` stays in sessionStorage on purpose: "come back to where I was" is
a property of the tab that navigated away, not of the session.

The e2e seeds move with the store. They forged tokens into sessionStorage, which
the SDK no longer reads — left alone, every primed spec would have started
signed out and the suite would have failed for a reason that had nothing to do
with what it was testing.
2026-08-03 17:05:27 -07:00
zooqueen 846069c98b onboarding: a refused create is not a complaint about the name
Hanzo CI/CD / cicd (push) Successful in 4m53s
CI/CD / cicd (push) Successful in 4m53s
Reported: signing in led to a create-account flow, an organization named
"coffee cups", and a refusal read as "organization name taken". The name was
free — there is no org matching it anywhere in the IAM store.

What actually fired is the FIRST-RUN GATE (iam internal/oidc/provision.go:156):
onboarding MOVES the caller into the org it founds, so founding a second would
strip this account from — and orphan — the org it already admins, along with that
org's billing account. IAM refuses with 409 "you already have an organization".
The console renders the server's message verbatim, so the customer was told about
organizations immediately after typing a name, and read it as being about the name.

This screen is only ever rendered when the client resolved an EMPTY owner
(entry/scope.tsx: `if (!owner) return <OrgOnboarding />`) — and an owner that is
empty because a read failed is indistinguishable from a brand-new account. So the
refusal is the first RELIABLE signal that the session was wrong, and the only
honest thing to do with it is recover, not apologise: read the account, and offer
the way into the org this identity is actually in.

THE STATUS ALONE CANNOT DECIDE THAT, which is the part worth naming. /v1/iam/onboard
answers 409 for two opposite reasons — the first-run gate above, and a name
genuinely held by another tenant (provision.go:190). What separates them is whether
this account is itself in an org: only the first has somewhere to go. readOnboardRefusal
takes that reading and returns recover-or-report, so a customer who really did pick
a taken name still sees the accurate message and is not bounced into someone
else's org.

Recovery is offered, never automatic — re-authenticating on our own would loop
forever against whatever left the session ownerless in the first place.

24 tests pass in onboarding.test.ts, 4 of them new and covering both 409s.
NOT yet browser-verified: this needs a real signed-in account whose session
resolves without an owner, which is the state I cannot manufacture locally.
2026-08-03 13:48:19 -07:00
hanzo-dev 06b7f5416a ia: one trigger for the command palette, not two
Hanzo CI/CD / cicd (push) Successful in 5m25s
CI/CD / cicd (push) Successful in 5m26s
The topbar carried a search box reading "Search or jump to… ⌘K" and, directly
beside it, an "Apps" button. Both called the SAME `useCommandPalette().open`.
Two adjacent triggers for one surface read as two different destinations, and
"Apps" in particular implied an app directory that does not exist — the
palette is what opens either way.

The mobile drawer had the identical pair, so it goes there too; otherwise the
rule would hold on a laptop and not on a phone.

Nothing is lost: the remaining control is the one that SAYS what it does and
shows its shortcut. This is the fifth of the five switchers, and the count on
the Overview at 1440x900 is now three — one per question:

  before  project (top-right) · account (bottom-left) · network (top-right) · apps
  after   context (top-left)  · account (bottom-left) · network (top-right)

Measured in a browser against the real shell, not counted by reading the JSX.

  tsc --noEmit  exit 0

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:37:06 -07:00
hanzo-dev 80cbfcbbb1 ia: org and project are one question, so they are one control
Five controls on one screen answered "who and where am I", and three of them
answered it from three different corners: the org mark top-left, the account
(which also switched tenant) bottom-left, and a project chip top-right beside
the network. Org and project are not two questions — they are "which tenant,
and which slice of it" — so they condense into ONE control at the top-left,
under the mark that already anchors the tenant.

Each question now has exactly one place:
  WHERE  ContextSwitcher, top-left     org + project
  WHO    AccountMenu, foot of the rail identity, team, settings, balance, exit
  MODE   ScopeSwitcher, top-right      network, with its tier dot

The network deliberately stays its own always-visible chip: it is a global
mode rather than a place, and the dot (mainnet green / testnet amber) is a
destructive-environment guard you must be able to READ without opening a menu.

There is still exactly ONE org switch. `org-scope.switchOrg` — the seam that
persists the scope and reloads so every module refetches under the new
`X-Org-Id`, and the seam tenant scoping and billing attribution hang off — is
imported, never reimplemented. The admin-gated, server-PAGED cross-tenant
search moved across whole rather than being reduced to a first page, so an
admin can still reach a tenant nobody is a member of. Verified against a
mocked cross-tenant list: typing "acme" narrows the org group to exactly
"Acme Industrial".

`adminOrgState` had no caller once the account menu stopped switching tenant,
so it is deleted rather than kept warm. The invariant it protected is now
pinned directly: `org-state.test.ts` scans every source file for `switchOrg(`
and asserts the caller set exactly. Mutation-tested — re-adding a switch to
AccountMenu turns it red, removing it turns it green.

One row, not two: the org/project/network menus rendered the same row shape
from two copies. That is `ui/MenuRow` now, so they cannot drift.

FINDING — a missing primitive, not worked around. A single-select list wants
ARIA `listbox`/`option`, and @hanzo/gui types `role` as React Native's
accessibility-role union: it admits `option` but NOT `listbox`, so an `option`
could never be given the parent ARIA requires. Used `radiogroup`/`radio`, the
single-select pair gui carries whole. @hanzo/gui should carry `listbox`.

`FieldText` grew an `ariaLabel`: a search field with no visible label had no
accessible name, and that is the console's own primitive to extend.

  tsc --noEmit  exit 0
  vitest        3156 passed, 8 skipped (255 files)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:36:44 -07:00
hanzo-dev 4656c316f4 build: react-native-svg — the dependency gui 8 needs and the bump forgot
`deps: console onto gui 8.x` moved @hanzo/gui 7.3.0 -> ^8.0.0. gui 8 pulls
@hanzogui/lucide-icons-2@8.0.0, whose ESM build does

    import { Svg, Path } from "react-native-svg"

while declaring react-native-svg in NEITHER `dependencies` NOR
`peerDependencies`. Nothing installs it, so webpack cannot resolve it, and
`next build` dies with a wall of "Module not found: Can't resolve
'react-native-svg'" — once per icon. The `react-native$: react-native-web`
alias in next.config.mjs does not cover it: `$` is an EXACT-match alias and
react-native-svg is a different package.

So console main has been UNBUILDABLE since that bump. That is why
ghcr.io/hanzoai/console has no v8.5.33, v8.5.34 or v8.5.35 — the tags were
cut, the images were never published, and universe's bump to v8.5.35 had to
be reverted to v8.5.32 to keep a pullable image. The three fixes riding those
tags (the 1px display line-box, the missing 404, @hanzo/iam 0.21.2) have been
finished in main and dark in production the whole time.

The estate already had the answer: every app on gui 8 carries this dependency
explicitly (hanzo.ai, hanzo.sh-std, app-std, console-std all pin 15.15.5),
and every app still on gui 7.3.0 does not need it. Console took the bump
without the companion. This restores the one established pairing.

Measured, not assumed:
  before  next build -> exit 1, "Can't resolve 'react-native-svg'"
  after   next build -> exit 0, 42 routes emitted
  pnpm install --frozen-lockfile -> exit 0 (the Dockerfile's exact command)

Lockfile regenerated with the DECLARED pnpm 11.17.0 (node 22), not the pnpm 9
on PATH — pnpm 9 renormalizes peer-suffix keys and churns 1061 lines. This
diff is +96/-0, purely additive.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-03 12:11:49 -07:00
zeekay d9bccc9e8d console: 400+ models, and two comments that cannot keep a number current
Hanzo CI/CD / cicd (push) Failing after 1m2s
CI/CD / cicd (push) Failing after 1m2s
The guide headline said "100+ models. One OpenAI-compatible API." — wrong against
api.hanzo.ai, which serves 444 priced entries (and lists 107 callable ids). 400+
is true under the catalog reading and is what the rest of the estate now says.

Two comments carried counts they had no way to maintain — "85+ models across a
dozen families" and "~340 models" — each written when it was true and silently
wrong since. A comment that states a number the code does not compute is a comment
that will lie; both now describe the shape without asserting a size.
2026-08-03 09:45:29 -07:00
zooqueen ffd291fedf keys: two shapes — sk- authenticates, pk- does not
Hanzo CI/CD / cicd (push) Failing after 1m5s
CI/CD / cicd (push) Failing after 1m5s
The console spoke of three key prefixes. IAM resolves two: sk- (secret,
same-tenant pinned) and pk- (publishable, refused at the auth door). An
hk- string is not a key, so nothing in a user-facing surface may offer it.

The one behavioural change is the workbench Inspector's prefix filter,
which routed hk-/sk-/pk- to the account key status; it now recognizes
sk-/pk- and refuses anything else, with the error text to match. The
pasted value is never sent — the Inspector reads the session's own key
status — so this narrows what the UI calls a key without touching auth.

The rest is copy: placeholders, Bearer examples, product Auth facts and
the guide step now name sk-, the credential the /keys route actually
mints. The workbench legend had sk- as a "provider key" and implied all
three ride the Authorization header; it now separates the secret that
authenticates from the publishable value that never does.
2026-08-02 13:31:55 -07:00
hanzo-dev 4da48f9274 billing: call the route names the server registers, and stop minting our own credit
Commerce dropped the compound prefixes from its billing routes. The /v1/billing/
namespace already says "billing", so billing/payment-methods stuttered. Both
servers now register only the short names, and the live edge agrees:
/v1/billing/methods 401, /v1/billing/settings 403, /v1/billing/alerts 403, while
payment-methods, payment-config and spend-alerts are all 404.

The console never followed. Its card list, its card save, its card detach and its
Square-config read were all pointed at routes that no longer exist, which means a
new user could not add a card. This was the revenue path, broken in production.
Alerts had already been repointed, so the four dead call sites were the three
payment-methods ones and payment-config; they now build methods and settings.
No alias, no fallback — one name per concept.

The tests were part of the defect rather than the guard against it. Every suite
around payment methods stubbed a response body and asserted the normalization, so
a client aimed at a 404 stayed green; that is precisely how this survived. The URL
is now pinned where the request is made, including the two reads nothing had ever
asserted, and reverting any short name turns the suite red — checked, not assumed.
The two e2e specs that pinned dead URLs are corrected, and the isolation spec also
had the retired /billing/v1/ prefix.

POST /v1/billing/me/welcome is deleted rather than repointed, along with the type
and the module that fed it. Commerce removed that route deliberately: it was a
self-service mint, a browser could grant its own org $5, and commerce's own
api/billing/mint_gates_test.go calls it the TOCTOU double-mint. Credit is minted
only through the mint-gated POST /v1/billing/credit. The call was already failing
silently, so restoring it would have re-opened a closed money hole in exchange for
nothing. The trial credit still arrives — commerce grants it server-side when a
card is vaulted, and signup grants it server-side — and that path is untouched.

Scope was measured, not guessed. /v1/finance/payment-methods is still alive and
/v1/finance/methods is 404, so the finance ledger keeps the compound name; a
blanket repo-wide rename would have broken it. The Billing Center tab slugs are
console page URLs, not server routes, and are unchanged.

Two headlines blamed the wrong layer. "Card top-up isn't available on this
deployment yet" and "Adding a card isn't available on this deployment yet" both
fire when the organization has no Square applicationId or locationId — per-org
configuration, not a property of the deployment. Both now name the organization,
as does the onboarding step that had the same defect, and the stale endpoint hints
beneath them now read settings.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-08-01 22:07:39 -07:00
hanzo-dev 7a37a8fd92 analytics: identify carries the attributes that make the id legible
identify() sent a user id and nothing else, so the warehouse held a population
of opaque subjects. Every funnel could count users and none could say which
user, and answering "who hit this" meant an IAM lookup per row — which is why
no user attribute is visible anywhere downstream.

Email and name are first-party facts about our own users, and they arrive in
the same IAM claims this file already decodes to get the id: accountFromClaims
projects them onto the Account and the bridge dropped them on the floor. The
SDK has taken traits since it took a person id; nothing new is collected, a
value already in the token is simply not discarded on the way past.

The scrubber does not touch this: capturePII gates error TEXT, and identify
traits ride build()'s `...extra` verbatim. Secret redaction is unconditional
and stays that way.

A key is omitted rather than sent undefined, so an absent claim cannot blank a
trait an earlier identify established. The org is still not sent — the tenant
is stamped server-side from the validated bearer, and a tenant the client can
name is a tenant the client can get wrong.
2026-08-01 14:54:48 -07:00
hanzo-devandhanzo-dev 29c98ddf5b analytics: attribute the stream with the IAM bearer, identify by sub
Two faults, one outcome: every console row landed in the $public tenant, a
partition no org can read, with no user attached. 498 rows, zero identified users.

CREDENTIAL. The client posted same-origin and trusted the first-party cookie to
carry the tenant. That cookie is the casibase session; cloud resolves a tenant from
a validated IAM bearer. A cookie-only POST carries no principal, and the door does
not refuse it — it takes the anonymous lane, which files rows under $public and
drops identify with a 200 receipt. Both ends look healthy. Pass the IAM access
token the console already holds; read it through a function, since the module is
built at import time when nobody is signed in yet.

No publishable key is passed instead, and the note in the file says why: a pk- maps
to one org while this image serves three brands resolved at runtime, and the SDK
resolves `ingestKey ?? token`, so a key would silently override each signed-in
user's own identity rather than supplement it.

IDENTIFIER. identify() sent `${owner}/${name}`, an org-relative reference in a
different id space from the IAM sub that hanzo.ai and hanzo.chat send — so one user
counted twice across surfaces, and the ref moves when an org or handle is renamed,
rewriting history. The sub was already decoded here and used only to back-derive
owner/name, then dropped; carry it through as Account.userId and identify by it.
Absent stays absent rather than falling back to the actor ref.
2026-08-01 12:33:07 -07:00
hanzo-dev f64de84a4e deps: @hanzo/event 0.3.8 — the release that redacts credentials from event URLs
0.3.8 stops the client shipping a raw location on every event. The location is
stamped on all of them now, so an invite/reset/magic link — a JWT in the query,
an address in `?email=` — reached the warehouse in cleartext on the first click
and again on every later one. url, path and referrer now get the same redaction
the error plane has always applied to error text.

Console is the surface where that matters most and the one furthest behind: the
running bundle serves libraryVersion 0.3.0, six releases back, so it has no
error plane at all (sentry.ts did not exist yet) and never stamped a page onto
an event. The lockfile has been ahead of the image for a while — this bumps the
lockfile; the image has to be rebuilt and its pin moved for any of it to be
true in production.

Lock edited only where it names @hanzo/event, so an unrelated tree is not
re-resolved into a dependency bump. Integrity is the one npmjs serves for
0.3.8; `pnpm install --frozen-lockfile` accepts it on pnpm 11.17.0, the version
package.json pins and the Dockerfile installs via corepack.
2026-08-01 12:25:22 -07:00
hanzo-dev ecb9be3a8b deps: @hanzo/event ^0.3.5 for page-stamped autocapture
Autocapture ($click/$input/$change) reaches the wire through capture(),
which supplied no location, so every $click landed with an empty url and
path and was unattributable to a page -- the one thing a heatmap needs.
0.3.5 stamps the page in build(), the single point every event is built,
placed ahead of the caller's fields so pageview()'s explicit path still
wins on the route changes that fire before window.location catches up.

The lockfile moves with the range here. What ships is the lockfile, not
the range, which is why production was serving 0.3.3 while the declared
range already read ^0.3.4. Resolves 0.3.6 (the 0.3.5 runtime plus a
test), deduped with @hanzo/observe and @hanzogui/telemetry.
2026-08-01 10:53:23 -07:00
zooqueen 400a206d80 api: follow cloud onto /v1/o11y/reviews
cloud renamed /v1/o11y/annotation-queues[…] to /v1/o11y/reviews[…] — o11y's own
comment already called them human-review queues, so "queue" was the
implementation and "review" the resource.

lib/api/o11y.ts makes the three reads (list, detail, items) and must move or
the Annotation Queues board renders empty. The rest here is prose that named
the cloud address, including o11y.ts's "mirrors" note and next.config.mjs's
dev-proxy comment.

The console's own `annotation-queues` page — route id, label and docs link —
stays, for the same reason the score-configs page did: renaming a page id
without its heading and its documentation leaves a page that matches neither.
2026-07-31 17:35:47 -07:00
zooqueen 628e6fb469 api: follow cloud onto datasets/:name/items and rubrics
cloud nested dataset items inside the set that contains them
(/v1/evals/dataset-items -> /v1/evals/datasets/:name/items) and renamed score
configs to rubrics (/v1/evals/score-configs -> /v1/evals/rubrics).

The item change is a real contract change, not a path swap: the dataset was
always required — as a body field on POST and a query param on GET — and is now
a path segment. So EvalsApi.createDatasetItem takes it as its FIRST ARGUMENT
and CreateDatasetItemBody no longer carries datasetName; listDatasetItems
builds the nested URL. DatasetsModule passes the set it already had in hand.

Two comments here were wrong before this commit and are corrected rather than
merely renamed: registry.tsx, ScoreConfigsModule.tsx and o11y.ts all claimed
this surface was `/v1/o11y/score-configs`. o11y has never served it — o11y.ts's
own note says scores and their definitions STAY on /v1/evals — so the address
they named did not exist under either spelling.

DELIBERATELY NOT RENAMED: the console's own `score-configs` PAGE (its route id,
its "Score Configs" label and its ${DOCS}/score-configs link). That is product
copy plus an external docs URL, and moving the id without the label and the
docs page would leave a page whose name matches neither its own heading nor the
documentation it links to. It is a coordinated rename with hanzo-docs, not part
of an api sweep.
2026-07-31 17:33:52 -07:00
zooqueen c1a7d48113 api: follow cloud off the spend-caps and block-storage compounds
cloud renamed /v1/admin/spend-caps[/:id] to /v1/admin/caps[/:id] and
/v1/admin/block-storage to /v1/admin/volumes — under /v1/admin there is one
kind of cap, and what the storage board returns is a list of volumes.

TWO literal allowlists carry these names and both are load-bearing, so this
cannot land after cloud without breaking the admin boards:
  - ADMIN_AGGREGATE_HEADS (src/lib/server/admin-aggregate.ts) — allowAdminSurface
    admits `v1/admin/<head>[/...]`, so the head is what lets the :id sub-path
    through at all.
  - ADMIN_V1_HEADS (next.config.mjs) — the dev rewrite onto a real backend.

lib/api/admin-spend-caps.ts -> admin-caps.ts, with AdminSpendCapsApi ->
AdminCapsApi and the AdminSpendCap type -> AdminCap, so the module, the API
object, the type and the route all say one thing; its test moves with it.
storage-fleet.ts calls the volumes read. UsageCapsPromoModule.tsx, client.ts,
registry.tsx and the aggregate route's doc comments name these addresses in
prose and would otherwise document routes nobody serves.

e2e/storage-fleet.spec.ts intercepts the cloud call by URL, so its matcher moves
too or the fixture never binds and the board renders empty.
2026-07-31 17:28:26 -07:00
zooqueen e5f510b994 api: follow cloud off the load-balancers compound
cloud renamed /v1/load-balancers[/:id] to /v1/balancers[/:id] — nesting under
/v1/networks was unavailable (apps/zt owns that prefix), so the flat single
noun is the one-way answer, matching its sibling /v1/vpcs.

proxy-allow.ts is the load-bearing edit: it is a literal FIRST-SEGMENT
allowlist, and `load-balancers` was a real entry, so without this every call
403s at the proxy before it ever reaches cloud. LoadBalancerModule.tsx calls
list/create/delete directly.

The admin Infra tab slug moves too — same concept, one name — while its visible
label stays "Load balancers", which is both what a person reads and what
e2e/admin-infra.spec.ts clicks. The response body is unchanged: cloud still
returns {loadBalancers:[…]}, DigitalOcean's own name for its own resource.
2026-07-31 17:24:43 -07:00
zooqueen 0a883aefaf api: follow cloud off the share-classes and equity-plans compounds
cloud renamed /v1/captable/share-classes[/:id] to /v1/captable/classes[/:id]
and /v1/captable/equity-plans to /v1/captable/plans: the captable prefix
already supplies "share" and "equity", so each member was repeating its group.

lib/api/captable.ts builds both URLs and must move or the Classes and Plans
panels 404. CapTableModule.tsx prints the address in a BackendStateCard hint,
which is only useful if it names the route that actually failed. proxy-allow.ts
admits by first segment, so `captable` is unchanged — only its comment, which
lists the sub-paths that head covers.

The ShareClass and EquityPlan TYPES keep their names: those are the domain
objects, and a share class is a share class wherever it is addressed.
2026-07-31 17:22:03 -07:00
zooqueen 0c097129d8 api: follow cloud off the rotate-secret compound
cloud renamed POST /v1/webhooks/:id/rotate-secret to POST /v1/webhooks/:id/secret
— the endpoint has one signing secret and POST is what mints a new one, so the
verb belonged to the method, not the noun.

WebhooksModule.tsx calls the address directly, so it moves or the rotate button
404s. proxy-allow.ts needs no allowlist change (it admits by first segment, and
`webhooks` is unchanged); only its comment, which enumerates the sub-paths that
head covers, would otherwise name an address nobody serves.
2026-07-31 17:19:38 -07:00
zooqueen 3749f2669f api: /v1/billing/alerts — follow the rename off the compound 2026-07-31 16:21:05 -07:00
zeekayandClaude Opus 5 06d0681081 deps: unfreeze console's first-party pins; quarantine finance-ui 0.2.x
@hanzo/canvas was declared "^0.1.0". A caret on a 0.x version pins the
MINOR, so console was locked to canvas 0.1.0 while 0.2.1 was current —
the same trap that froze @hanzogui/shell on 7.5.1. @hanzo/ui@8.0.38
peer-depends on canvas ">=0.1.0", which 0.1.0 satisfies, so nothing
warned. Silent freeze.

Six exact pins (dash, gui, and the four @hanzogui/* entry points) could
never take a patch either. Floated them to carets; 8.x is a stable major
so a caret is the correct expression.

Moved on install: brand 1.4.4->1.4.5, canvas 0.1.0->0.2.1,
data 1.2.1->1.2.2, logo 1.0.13->1.0.14, ui 8.0.20->8.0.38.

@hanzo/finance-ui stays on 0.1.1, now written "~0.1.1" so the range says
so. 0.2.1 declares peer "@hanzo/ui": ">=8.0.0" — satisfiable, so the peer
check passes — but its source imports DataTable, LineChart, Column,
ChartPoint and Sparkline from @hanzo/ui, and no published @hanzo/ui
exports them (verified against 8.0.20, 8.0.38 and ui-shadcn 5.9.1).
It publishes raw src/, so those errors land in the consumer's tsc.
A satisfiable-but-false peer range is worse than an unsatisfiable one:
it fails at build time in the consumer instead of at install.

tsc --noEmit clean; next build succeeds; 3152 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:49:51 -07:00
zeekay cdb6f9d091 deps: console onto gui 8.x — the shell fixes can finally reach production
Hanzo CI/CD / cicd (push) Failing after 1m7s
CI/CD / cicd (push) Failing after 1m7s
The console sat on @hanzo/gui 7.3.0 / @hanzogui/shell 7.6.3 while the gui line
shipped 8.0.0, so every shell fix landed on main and stopped there: cloud.hanzo.ai
and console.hanzo.ai kept serving a header from a package that could not move.

What actually pinned it was a phantom peer. Every published @hanzogui/shell
declared `@hanzo/iam@^0.13.1` — a package it never imported — and for a 0.x range
the caret pins the minor, so once IAM reached 0.21.2 the range was unsatisfiable
and the 8.x line was uninstallable. Fixed at the source and released as
@hanzogui/shell@8.0.1 with zero dependencies.

typescript goes ^7.0.2 -> ^5.8.2 in the same commit because it has to: Next 15
does not support TS 7, and on 7.0.2 `next dev` fails to a bare
"Cannot read properties of undefined (reading 'endsWith')" after silently
installing a 5.x behind your back. Same fix hanzo.ai just made.

Verified on the real 8.x tree at 1440x900 and 390x844: Products opens as FIVE
columns with all ten categories visible and nothing clipped (it was four columns
with WEB3 + APPS below the fold), one "Sign in" pointing at /signin, one h1,
the hero primary a white 999px pill, and Terms inside the viewport with no
underline and no horizontal overflow.
2026-07-31 14:35:28 -07:00
hanzo-dev 744164c4c5 merge: one host normalizer for the brand/admin-gate boundary
rescue/stash-0: brandFromHost routes through normHost, which now trims
before stripping the port and drops the FQDN root dot. A padded, ported
or dotted host can no longer soften the suffix match and swap adminDomain
onto the default brand. Tests cover the padded/ported/dotted/lookalike cases.
2026-07-31 14:30:36 -07:00
hanzo-dev 454636c4e2 merge: subscribe the console to the DocType engine's change feed
feat/framework-realtime, additive: framework client gains a change-feed
subscription plus its types and tests. No existing surface changes.
2026-07-31 14:30:36 -07:00
hanzo-dev 8bdbc9116a telemetry: say why the console bakes no ingest key
The env var is read but is undefined in every shipped artifact, which
reads like an oversight and invites a build arg. It is not one.

A pk- resolves to exactly ONE org -- cloud stamps the tenant from the key
-- and this image is brand-agnostic: one build serves cloud.hanzo.ai,
cloud.lux.cloud and cloud.zoo.cloud with the brand resolved at runtime
from the request hostname. Baking a key would file every brand's traffic
into whichever org owns the key: wrong data, and a cross-tenant leak. It
is the same reason the Dockerfile bakes no NEXT_PUBLIC_*.

Signed-in traffic does not need one. host:'' posts same-origin, so the
first-party session rides along and cloud resolves the tenant from it at
full capability; identify and track already land correctly.

What is genuinely unattributed is the logged-out lane, which reaches
cloud with no credential and takes the anonymous lane -- pageview and
error stored, track/identify/group dropped, 200 either way. Closing it
needs a per-host key delivered at runtime over a channel both artifacts
share, which is recorded here so the next reader does not reach for the
build arg instead.
2026-07-31 10:46:36 -07:00
hanzo-dev 599f6f3a31 billing: Stripe is back in the connector list — Square stays our rail
A merchant connecting THEIR Stripe account is a capability we offer; only
Hanzo's own charging is Square-only. Square leads the list because it is
the rail this platform bills on. The doc-comment cleanups stay: describing
a payload SHAPE by a vendor's name was always vague, whoever we charge on.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 21:52:02 -07:00
hanzo-dev f26c8fbcca billing: the UI stops naming a rail we do not use
Square is the rail (via commerce), so the console says so: the payments
integration list drops Stripe, and every doc comment that reached for the
brand to describe a SHAPE — a nested subscription record, a seconds-vs-
millis period stamp, snake_case card fields, the developer-workbench
pattern — now describes the shape instead. No behavior change; the
normalizers still accept the same payloads, which is what the 25 billing
tests prove.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 21:22:43 -07:00
hanzo-dev 447825ef6d api: read the named total first; data2 is only the legacy fallback
The count of a list envelope now reads the named field before Casdoor's
untyped second slot: total, then data2, then the rows themselves. ONE
helper (envelopeTotal in lib/api/client.ts) owns the order; getList,
iamList, makeIamClient and AuditApi.list all go through it, so the data2
fallback lives in exactly one place and dies with the legacy emitters.

e2e fixtures still emit data2 on purpose — they pin today's live wire
and flip only when the fallback is deleted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 12:32:55 -07:00
hanzo-dev 9915705d10 referrals: signup, one vocabulary
Hanzo CI/CD / cicd (push) Successful in 6m2s
CI/CD / cicd (push) Successful in 6m2s
The status and the counts field say signup (was signed_up / signedUp),
matching the cloud rename that migrates the store rows — the whole
platform now spells the concept one way.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-30 10:02:01 -07:00
hanzo-dev b10c5ce18f identity: a refusal, a network error and an absence are three answers
IAM v1.33.31 made org scoping honour-or-refuse (internal/authz/authz.go Scope):
a non-SuperAdmin asking about a foreign owner is REFUSED, not silently re-pointed
at its own org. The confidential `hanzo-console` client is exactly the principal
that starts earning 403s, and this module read every failure as `null`.

`iamGetData` returned null when unconfigured, null on a thrown fetch, and null on
`!res.ok || status !== 'ok' || data == null` — so a 403 refusal, an unreachable
IAM, and a malformed envelope were indistinguishable from "no such row". IAM's
own getHandler (internal/compat/aliases.go) answers three DIFFERENT things from
one endpoint:

  hit      200 {status:"ok", data}
  absence  200 {status:"error", msg:"the entity does not exist"}   (httpx.Err)
  refusal  403 {status:"error", msg:"forbidden: …"}                (authz.Deny)

Collapsing them cost real behaviour, not just tidiness:

  - getMember is the invite flow's identity read. A refusal became "this
    invitation is no longer valid — the member was removed" (410), telling an
    invitee their membership was revoked when IAM merely would not answer, and it
    is the same null the single-use activation guard reads.
  - getUserKey is the API-key state read. A refusal became "no key", which is the
    exact regression its own docstring records: the page falls back to Create, the
    live key is hidden, and the user mints a duplicate over a key they can no
    longer revoke.

So: ONE transport (`iam`) that returns data or throws, carrying `absent` for the
one benign kind — the same contract and the same wire constants as the app's
client (ai-sdk lib/org/onboard.ts) and cloud's Go client (apps/account/iam.go
`do`), which is why `method` is explicit here: IAM has param-only POSTs. Over it
sit exactly two named policies: `iamGetOrAbsent` (absence is a legitimate answer
to null, everything else propagates) and `iamGetUser` (fail-SOFT, and safe
precisely because the admin gate admits only on positive evidence, so a lost
answer can only ever DENY). Soften where a lost answer closes a door, never where
it opens one.

That replaces FOUR transports — iamGetUser, iamCall, iamGetData, iamPostBody —
which had three different error conventions between them.

Also drops the org-onboarding half (getOrganization, createOrganization,
createUser, moveUserToOrg, IamOrganization): dead since onboarding moved server
side to cloud's POST /v1/iam/onboard (apps/account/account.go), which
OrgOnboarding.tsx calls via v1Url('iam/onboard'). Zero consumers — tsc --noEmit
over the whole app is the proof, not a grep. Deleted outright, no shim.

The one behaviour change beyond the above: the transport now refuses when the
confidential client is unconfigured instead of sending Basic Og== and letting IAM
401. Both throw; this one says why and skips the round-trip. Every route already
checks mintConfigured() first, so the three identity.test.ts cases that relied on
the old unguarded path now configure the client, which is the production
precondition they were always standing in for.

Tests: identity-refusal.test.ts pins all three answers plus the unreachable and
malformed cases for both live readers, and pins that a REFUSED claims read still
fails the admin gate CLOSED. 6 of its 11 fail against the code this replaces.
Suite 3141 passed / 8 skipped (was 3130 / 8); tsc --noEmit clean; next build
compiled successfully.
2026-07-28 23:30:58 -07:00
zooqueen d93e9148b2 docs: the identity server these comments describe is IAM, not a vendor
Every hit was a comment naming the wrong system. hanzoai/iam is Hanzo IAM —
original, clean-room work; the vendor-derived server was hanzoai/iam-v1, which
is retired and ships in nothing. Replaced the name with IAM and kept each
comment's fact intact: tokens really are ~3.6 KB full-user JWTs (hence the two
cookies and the chunking), IAM really does pack the full user object, and it
really does skip the client-secret check when the secret is empty.

Comments and markdown only — no identifier, cookie name, or wire field moved.
2026-07-28 17:12:30 -07:00
zooqueen 6b881b2a8e docs: /zap is served — the reason recorded for preferring REST is stale
This file says "the cloud /zap WS face is NOT served (the edge returns SPA
HTML, 200 not a WS upgrade)". That was true when written and is not true
now. Measured 2026-07-28 on api.hanzo.ai, platform.hanzo.ai and
cloud.hanzo.ai: a WebSocket upgrade handshake returns 401 on all three —
not SPA HTML, not 200. A route that refuses an unauthenticated upgrade is
a route that exists.

Read as current, the paragraph says ZAP is unavailable when it is merely
gated, which is the kind of stale rationale that gets acted on. The REST
path it describes is still correct and still shipping, so nothing about
the code changes — only the reason.

Marked stale in place rather than deleted: the history explains why
Providers moved to REST, and removing it would lose that. The correction
names what was measured and where, so the next reader can re-check rather
than trust either version.

Found while sweeping for docs that actively mislead. The sibling case,
universe crs/console.yaml justifying replicas:2 with an admin.hanzo.ai
route that no longer exists, needs no fix — that CR has since been
retired with the standalone console.
2026-07-28 13:30:59 -07:00
hanzo-dev f85b08e44d console: name the fleet board by its one name
Cloud folded /v1/paas into /v1/platform — paas was a second name for the same
product, and one product gets one name. The operator fleet/drift board now lives
at /v1/platform/fleet.

No functional change here, and that is the point: the `platform` allowlist head
already admits every /v1/platform sub-path, so the new route was reachable the
moment cloud shipped it. What was stale was the PROSE — three comments still
pointed readers at `/paas/apps`, a route that no longer exists, and the
allowlist's own sub-path list did not mention the board it admits. A comment
that names a dead route is worse than no comment: it sends the next reader to
look for a surface that was deleted.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 11:45:18 -07:00
hanzo-dev daef1ea32c console(framework): subscribe to the DocType engine's change feed
The generic renderer had no realtime at all — zero EventSource, zero subscribe.
This adds the client half of the ONE mechanism the engine now serves:

  changes.list(q)        one page of the feed  -> GET /v1/framework/changes
  changes.subscribe(q,h) the same query held open as SSE -> /v1/framework/stream
  presence.list(dt,name) the roster            -> GET /v1/framework/presence

changeQuery is ONE function for the poll and the stream, because server-side
they are ONE query — the stream is that query in a loop. A view renders current
state from records.list, keeps the cursor it gets back, and applies changes from
there.

No credential and no org leaves the browser. subscribe opens a same-origin
EventSource through the console's existing /v1 bearer proxy, which already
streams res.body straight through; the proxy mints the short-lived user-bound
token and the engine resolves the org from its owner claim. There is nothing a
page could send to name another tenant, which is the reason to prefer SSE here
over a WebSocket that would need its own auth path.

Resume is the browser's job: each frame's id IS the change's seq, so a dropped
connection reconnects with Last-Event-ID and misses nothing. onReset fires when
the cursor fell behind the server's retention window — refetch, THEN resume,
never patch on top of a state you never had.

Passing watching: '<DocType>/<name>' additionally declares presence for as long
as the connection lives, which is why presence needs no client->server channel.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 10:58:12 -07:00
hanzo-dev 7a722f0a3c feat(console): the shared components report through the console's ONE client
@hanzo/ui 8.0.20 instruments itself — DataTable, PrimaryButton, SlideOver,
ConfirmDelete, the Field* editors, ComboBox, Segmented/SearchInput, MenuItemView,
OrgSwitcher, ThemeToggle, Toast and EmptyState now report what a user did. The
console renders all of them, so it gets the whole interaction vocabulary without
a line of app code — that is the point of instrumenting the component instead of
the app.

But those components emit through module-scope `track()`, which resolves an
AMBIENT client, and left alone that would have been a SECOND client: default host
api.hanzo.ai, no session cookie, its own anon id and its own batch. Cloud's
anonymous capability lane admits pageview and error only, so every component
event would have been DROPPED on arrival — a silent, plausible-looking nothing.

So `src/lib/event.ts` — the file that already declares itself the ONE console
client — registers `eventClient` as the ambient one. Now the provider's pageviews,
the error boundaries, and every shared component ride a single same-origin batch
that carries the session cookie, which is what makes their events CREDENTIALED and
attributable to the signed-in org. One client, one anon id, one stream, exactly as
that file's header always promised.

Also pins typescript back to 5.9.3. Under the 7.0.2 that main had taken, Next
15.5.19 stops honoring the tsconfig `paths` map and the build dies on every
alias — `Can't resolve '~/config'`, `'~/lib/event'`, `'~/components/ProductRoute'`
— i.e. main could not build at all, before and independent of this change. Adding
`baseUrl` does not help; only the TS pin does.

Build: 8.0.20 installs clean under pnpm 11 (8.0.19 could not — it shipped
`workspace:*`, fixed upstream) and `next build` completes.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 09:22:39 -07:00
hanzo-dev 58ff94c159 ci: delete the GitHub puller — one direction, decided elsewhere
This workflow polled github.com every 10 minutes and fast-forwarded the forge
from it. It was written when which side was canonical was still open; it is not
open now, so a cron that reconciles two mains is a second answer to a settled
question. Removing it leaves exactly one way for code to move.

It was already inert by its own admission — the header notes it does nothing
while the GitHub repo is a pull mirror, because the forge overwrites main on its
own timer and rejects the push.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 08:57:09 -07:00
hanzo-dev b489ab0fdb ci: pin .hanzo/workflows/build.yml@v1
v1 is the .hanzo/workflows era. There is no v2 — one tag, forward only.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:31:05 -07:00
zandhanzo-dev 13c5b30cd6 build: regenerate the lockfile for @hanzo/ui ^8.0.17
Hanzo CI/CD / cicd (push) Failing after 47s
CI/CD / cicd (push) Failing after 47s
CI fails at install, before anything is built:

    [ERR_PNPM_OUTDATED_LOCKFILE] Cannot install with "frozen-lockfile"
      specifiers in the lockfile don't match specifiers in package.json:
      - @hanzo/ui (lockfile: ^8.0.11, manifest: ^8.0.17)

package.json was bumped without regenerating the lockfile, and `--frozen-lockfile`
refuses — correctly; that is what frozen means and the strictness is worth
keeping.

Resolves to 8.0.18, the newest release satisfying ^8.0.17.

Regenerated with pnpm 11.17.0, the version this repo declares in
`packageManager`, so the file matches what the builder's corepack will use. The
diff is large but it is not a format rewrite: lockfileVersion stays 9.0, the
change begins exactly at the @hanzo/ui specifier, and only 30 package entries
are added (2 removed) — lucide-react, sonner, tailwind-merge, react-remove-scroll
and friends, i.e. what @hanzo/ui pulls in between 8.0.11 and 8.0.18. The rest of
the churn is peer-hash suffixes on existing entries. packages and snapshots both
count 1060, so the file is self-consistent.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:21:32 -07:00
zeekayandhanzo-dev 108977e0ca fix(deps): take @hanzo/iam 0.21.2 and delete the patch it supersedes
`patches/@hanzo+iam+0.13.6.patch` added a fallback that reads the token's
`exp` claim when the server omits `expires_in`, so session expiry is still
learned. It was written against 0.13.6; the dep has since moved to 0.21.1,
so patch-package refused it — and, because a version mismatch is a
warning-class failure there, `postinstall` still exited 0. The patch had
silently stopped applying with nothing failing: verified absent from the
installed 0.21.1, so the console was running without the fallback.

Fixed upstream instead (hanzo-js/iam 08476b4, released as 0.21.2) where it
belongs — one implementation, every consumer — so the patch is now
genuinely unnecessary, which is what patch-package itself reported.

Verified: 0.21.2 installed, the fallback present in node_modules, and
postinstall clean ("No patch files found"). typecheck 0 errors; 3136 tests
pass.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-28 00:04:04 -07:00
hanzo-dev 137432ad42 refactor(gui): import the ONE scale, stop declaring it
gui.config.ts declared the console's type/radius/space ladder. It is not the
console's ladder — it is the ladder every @hanzo/ui/product component is drawn at,
and the dedicated Hanzo Social app renders those same components. A private copy
would have forked the moment either side tuned a size.

It now ships with the components (@hanzo/ui/gui-config, 8.0.17); this file is the
console's import path onto it, so `~/gui.config` call sites are unchanged.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:30:51 -07:00
hanzo-dev 9bd12d7c28 refactor(social): render the shared surface, not the console's private copy
The whole social product lived HERE — a 601-line SocialModule plus its own copy of
the /v1/social contract in src/lib/api/social.ts. That is fine while the console is
the only host, and it stopped being true: social.hanzo.ai gets a dedicated app, and
the only way for a second app to render Publish was to copy 800 lines and let them
drift.

So the product moved to @hanzo/ui/product/social (SocialResource + createSocialApi)
and the console keeps only what is genuinely the console's:

- src/lib/api/social.ts is now the TRANSPORT binding — the four verbs on
  originV1Url('social/…'), through our own app/v1 user-bearer BFF — and re-exports
  the contract so call sites keep one import path. It binds
  @hanzo/ui/product/social/api, the React-free entry, so the console's data layer
  (and its node tests) never load a component tree.
- SocialModule.tsx is the mount: hand SocialResource the bound client. 23 lines.
- The tests split the same way the code did: the contract's normalizers and paths
  are tested in @hanzo/ui; what is tested here is what is ours — that a contract
  path resolves to this origin's /v1/social/… .

817 lines of module + client become 77. Nothing about the surface, the routes, or
the tenant isolation changes: the org is still resolved SERVER-SIDE from the bearer
owner claim, and social.hanzo.ai still boots straight into this product.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:20:42 -07:00
zeekayandhanzo-dev bc93b371f6 build: typescript ^7.0.2 (native compiler)
TypeScript 7 is the native Go compiler; the npm package is a shim resolving
a platform-specific native binary. The build is `next build`, so SWC does the
emit and tsc is typecheck-only — no built artifact changes.

Measured on this same config before and after: 0 errors on both tsc 5.9 and
tsc 7, so the compiler swap introduces nothing new.

Note: `pnpm install` exits non-zero here on an unrelated, pre-existing
postinstall failure — patches/@hanzo+iam+0.13.6.patch no longer applies
because the installed @hanzo/iam has moved past 0.13.6. That failure
reproduces with the previous package.json and is not caused by this change.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 23:07:47 -07:00
hanzo-dev 2dc202092b admin/kms: the org rides the identity channel, not the URL
Cloud's KMS surface is /v1/kms/secrets now — URL-addressed orgs were removed
because a path that names a tenant is caller-selectable. The proxy keeps its
policy predicate exactly (brand org, or the SuperAdmin's ?org= switch) and
expresses the result where every other subsystem already reads it: X-Org-Id,
the acted-on org the identity boundary mints for a switched-in SuperAdmin.
The module footer stops advertising a route that no longer exists.
2026-07-27 21:22:49 -07:00
hanzo-dev eeeeab7074 merge origin/main 2026-07-27 21:03:30 -07:00
hanzo-dev 73cdffbefa console: one word for the platform-sudo gate — SuperAdmin
The gate had three names for one concept: the component was OperatorAccessRequired,
the headline said 'Operator access required', the body said 'an admin role', and the
predicate underneath was useIsSuperAdmin. Lux carried a fourth copy — its own
hardcoded 'Operator access required' whose body told a signed-in operator to
'sign in with an operator account', which this repo's own P1 rule forbids: a 403 is
signed-in-but-not-authorized, never a sign-in prompt.

The structure was already one-way — one component, one predicate across 27 call
sites. Only the naming forked. So: SuperAdminRequired, and the headline is a single
exported SUPERADMIN_REQUIRED that Lux's error card now shares, so the string cannot
drift again. The body states what the predicate actually tests — membership of the
reserved admin org.

Also adds an e2e for the research dashboard, written so it can fail. The console is
a SPA behind a catch-all: every path returns 200, so a status-code test passes after
the route is deleted. And /research is behind AuthGate, so an anonymous visitor sees
neither the dashboard nor the gate — measured, the body reads 'Sign in to your
account'. The two tests that run therefore prove the gate holds and that a nonsense
path renders no dashboard; each anchors on the shell having rendered first, because
an absence assertion is otherwise satisfied by a dead host. Verified: green against
cloud.hanzo.ai, both red against an unreachable one. Proving the dashboard paints
needs a SuperAdmin session, so that test is staged behind HANZO_PASSWORD rather than
faked green.
2026-07-27 21:03:22 -07:00
zeekayandhanzo-dev e25d198ea4 build: typescript ^7.0.2 (native compiler)
Hanzo CI/CD / cicd (push) Failing after 21s
CI/CD / cicd (push) Failing after 36s
TypeScript 7 is the native Go compiler; the npm package is a thin shim
that resolves a platform-specific native binary.

Safe here because the build does not emit through tsc — the bundler does,
so tsc is typecheck-only and the built artifact is unaffected. Gated on a
measured comparison of both compilers against this same config: TS7
introduces no errors TS 5.9 did not already report.

typescript now 5.9.3

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 19:11:44 -07:00
hanzo-dev 1bb596309a fix(social): carry a post's media instead of silently dropping it
Cloud's Post has always had `media []string` (store.go, always serialized as an
array, never null) but the console type and normalizePost omitted it, so the
field was invisible here — and destructively so: updatePost rebuilds the row
from the request body with `Media: normMedia(body.Media)`, meaning any PUT that
round-tripped a console-normalized Post would wipe the post's media. Nothing
calls posts.update today, so this is latent rather than live.

- Post.media + normalizePost, via one `strs` coercion helper that
  normalizeProviderCapability's inline duplicate now folds into.
- Show the URLs in the post detail drawer, so the data is visible rather than
  parsed and discarded. No upload affordance: cloud has no media endpoint, and
  inventing one would be dishonest.
- LLM.md: replace the stale "embed the Hanzo Social dashboard" TODO — it still
  described the retired standalone social-frontend as live and listed
  PostComposer as unextracted. It now records the cutover (social.hanzo.ai IS
  this console in social-only shell mode), the @hanzo/ui/product/social parts,
  and the exact publish blocker keeping SocialModule on its local copies.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
(cherry picked from commit eb1d7a6056aa7d2daec80ce17dc7d60794aaaca1)
2026-07-27 18:50:04 -07:00
zooqueen 8c2476fff1 console: the meta description leaked Hanzo's name on Lux and Zoo
generateMetadata already resolves the title from the request host — a
Lux console renders <title>Lux Cloud Console</title>. The description
beside it was a literal and shipped 'Unified admin console for Hanzo
Cloud and all cloud products.' to console.lux.cloud and
console.zoo.cloud.

The correct title is exactly why nobody caught it: the visible tab looked
white-labeled, so the head element behind it was never read.

Both strings now come from brandName. Every brand-visible value in this
function must; a literal here re-opens the leak.
2026-07-27 18:33:26 -07:00
zeekayandClaude Opus 5 a9464d7264 fix(nodes): probe luxd at /v1/bc/P and /v1/info -- /ext/ is a 404
Every node row on this route was silently 'not-reporting': luxd serves one
HTTP prefix and it is /v1. Measured 2026-07-27 against api.lux.network:
  /ext/info -> 404      /v1/info -> 200 {"version":"luxd/1.36.2",...}
  /ext/bc/P -> 404      /v1/bc/P -> 200

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:15:49 -07:00
zeekayandhanzo-dev 95d5f0bbaa fix: declare CSS side-effect imports for TypeScript 7 (TS2882)
TypeScript 7 reports TS2882 for a side-effect import with no type
declaration; TS 5.x accepted them silently. Next.js resolves stylesheets
through its own loader, so `import './globals.css'` never reaches the
TypeScript module resolver — the ambient declaration tells the checker
they are legitimate rather than giving them a shape.

Closes the entire TS7 gap for this app:

    before   ts5.9 25 errors   ts7 32 errors   (7 unique to TS7, all TS2882)
    after    ts5.9 25 errors   ts7 25 errors   (0 unique to TS7)

No regression on 5.9. The remaining 25 are pre-existing on both compilers.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 17:49:52 -07:00
zeekayandhanzo-dev 4d5cc48b4d fix: complete the TypeScript 7 tsconfig migration
The first pass left three classes of config that BOTH tsc 5.9 and tsc 7
reject. Each was proven against both compilers before changing:

  TS5090  A `paths` target must be relative once `baseUrl` is gone. The
          first pass skipped the "./" prefix wherever baseUrl pointed at
          the config's own directory, reasoning it was semantically
          equivalent. It is not — without baseUrl a non-relative target
          is rejected outright, by 5.9 as well as 7.

  TS5110  `moduleResolution: node16` requires `module: node16`. The first
          pass mapped commonjs projects to node16 resolution alone, which
          BROKE those configs for the current toolchain. Both are now set.

  TS5102  `downlevelIteration` is also removed in TS7; it was missing from
          the dead-flag list.

Verified: repos that tsc 7 previously refused (base-studio, js-sdk, kv-js)
now report zero config errors on tsc 7 AND tsc 5.9.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 17:49:52 -07:00
zeekayandhanzo-dev 2f3495ce53 build: migrate tsconfig to TypeScript 7 (native compiler)
TypeScript 7 is the native Go compiler and removes `baseUrl` and
`moduleResolution: node|node10`. Both appear here, so `tsc` from TS7
refuses the config outright (TS5102 / TS5108) and cannot typecheck.

`paths` targets resolve relative to `baseUrl` when it is set and relative
to the tsconfig file otherwise. Every `baseUrl` folded here already
pointed at the config's own directory, so dropping it moves nothing and
the targets are left byte-identical. Where a baseUrl pointed elsewhere,
each affected target was rewritten as join(baseUrl, target).

`moduleResolution` was chosen from the declared `module`: commonjs ->
node16, esnext/preserve -> bundler. Configs whose `module` is unset or
exotic were left alone rather than guessed at.

The result is accepted by BOTH toolchains, so nothing has to upgrade
TypeScript in lockstep. Verified on hanzo/chat packages/api: tsc 5.9
779 -> 778 errors (no regression), and tsc 7.0.2 now runs the project
in 2s where it previously refused the config.

Co-Authored-By: Hanzo Dev <dev@hanzo.ai>
2026-07-27 17:49:52 -07:00
hanzo-dev be65648bd0 merge origin/main 2026-07-27 14:51:53 -07:00
hanzo-dev f54074fa14 merge: name our own surfaces, not the upstream projects behind them
# Conflicts:
#	src/components/products/store/logic.ts
#	src/lib/api/admin-o11y.ts
2026-07-27 14:47:21 -07:00
hanzo-dev 12fc3c53b9 console: name our own surfaces, not the upstream projects behind them
Every string a user or operator reads should name the Hanzo product, not the
OSS project it is built on. Fourteen places still read the other way:

- Deploy (registry + GitOpsModule): the catalog description, the `gcp`
  equivalence row, the page subtitle, and the empty-state bullet all said
  "No ArgoCD". The `gcp` field renders as an "Equivalent to" row on six
  surfaces, so an operator read "Deploy — Equivalent to: Cloud Deploy /
  ArgoCD" (which is also wrong: ArgoCD is not a GCP product). Now: the Hanzo
  operator reconciles.
- Functions overview: the health tile read "Fission health" — the same file
  that renders the tab already documents the rule ("never the OSS name").
  Now "Functions health".
- The three telemetry 501 cards (Metrics, Status, Lux Network) named the
  telemetry engine as ours in operator-visible body text; the Lux one did it
  on a Lux-branded surface. They now say "the telemetry store". The `VM_URL`
  env var is UNCHANGED — something reads it.
- Vector empty states (collections + product landing): the index a collection
  maps to is ours; it reads "Vector/Search index" now.
- App Store: provenance tags (`caprover`, `dokploy`, `casaos`, …) were kept
  out of the quick chips but `availableTags` still handed them to the "All
  tags" expander, so a customer browsing our store saw chips naming other
  marketplaces. They are dropped from the browsable set — the set itself is
  catalog-derived data and stays, and free-text search still matches it.
- admin-o11y doc comment: our own tables were described with the dead
  `signoz_*` prefix, stale since the o11y debrand landed.
- The GitHub-sync workflow called our forge "this Gitea" twice; endpoints.md
  called our backend "the casibase API".
- storage-fleet e2e fixture: `pvc-signoz` / service `signoz` were screenshotted
  into e2e-shots inside a Hanzo operator board.

Attribution is untouched. NOTICE, LICENSE, the registry's `upstream` rows, and
every provenance comment (Gitea/Casdoor/Temporal/ArgoCD/PocketBase/SeaweedFS/
Fission/Langfuse lineage notes) are left exactly as they are — they explain
where a design came from and are legally load-bearing.

Not changed, deliberately: the Datastore "Connect" snippet still names
`clickhouse-client`. It is a command a customer copies and runs, and
packages.hanzo.ai does not serve a `datastore-client` yet — renaming it would
hand people a command they cannot install. That one needs the client published
first.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 13:55:21 -07:00
zooqueen f6fb4ae8f9 refactor(shell): three Panels become one; the gate goes green on real defects
Follow-up to the scale/ladder pass. Every change here was found by RUNNING the
gate against the shell and reading what it caught, not by reading source.

THREE PANELS BECOME ONE. Adding a `Panel` for the settings surface made it the
third: `ui/Metric.tsx` had one for chart bodies (13 consumers) and
`overview/living/tiles.tsx` had a private one. Same concept — a rounded surface
with a quiet header — carrying three paddings, two title sizes and two names for
the header slot (`right` vs `actions`). Now one definition in `ui/Panel.tsx`,
one import path, one name per prop. The body is CONTENT (padded) or `rows`
(a `Row` list that pads and separates itself) — the flag names the content
shape, not a style, which is why it is a flag and not a fourth component.

That unification also fixed a real mobile bug. The tiles' `minW={340}` inside a
366px pane painted to x=432 at 390 wide, clipped with nothing to scroll it. A
pixel minimum bigger than its container is always wrong; `min(340px, 100%)` says
the actual intent — this wide, never wider than the space there is — with no
breakpoint to get backwards. Same class in the Resources header, where a bare
View is `flex-shrink: 0` so it held its 533px max-content width and never
wrapped: the v8.5.29 landing-footer defect, in a second place.

THE ACTIVE STEP MARKER WAS 3.36:1. White on `$color9` — below AA, and measured,
not eyeballed. On the monochrome ladder the CURRENT step should be the strongest
surface: `$color12` with `$color1` ink, ~18:1, and it reads as "you are here"
rather than as a mid grey.

`Monogram` (ui/Monogram.tsx) — an avatar's initials scale with their circle, so
they are a graphic, not app text, and the gate skips them. It has to be a real
DOM element: react-native-web DROPS unknown props, so `data-monogram` written
onto a Gui `<XStack>` looked applied in the source and was absent in the page.
It also has to sit around whole distributed components, because @hanzo/ui paints
the org mark itself — so the gate additionally requires text of at most three
characters, and a marker placed that widely still cannot exempt a label.

The product rail is now a real `<nav aria-label="Products">` landmark. It had no
role at all, so a screen-reader user could neither jump to the product list nor
skip past it. `display: contents` adds the landmark with zero layout effect.

GATE CORRECTIONS, each because the rule was wrong, never to make a failure go
away — every one is narrower or more truthful than what it replaced:
 - a closed drawer parks off screen BY DESIGN (nav at -320, account at +390), so
   an `aria-hidden` subtree is not content;
 - a wide DataTable already scrolls inside its own container, so painting past
   the edge only counts when nothing can scroll to it;
 - the rail mixes rows and columns, so "tops never decrease" was simply false —
   it now asserts every reachable row is inside the viewport;
 - @hanzo/gui pins its portal host to a hardcoded 105001 that no console config
   can reach. Excluded by its own class marker and REPORTED, rather than raising
   the ceiling and quietly letting our own literals back in.

Gate: 6/6 green. It failed 6/6 before these fixes, and each failure was a real
defect — GET STARTED, the monogram, 3.36:1, the clipped tile.
2026-07-27 12:23:04 -07:00
zooqueen e67dad275a feat(shell): one type/radius/spacing scale, one z ladder, zero all-caps
The shell had three type scales, thirteen radius spellings, an odd-pixel
spacing ramp, and a stacking order expressed as literals up to 100002. This
makes each of those exactly one thing, and adds the gate that keeps them one.

THE ONE SCALE (gui.config.ts). app/design/typography.css declared the compact
register (11/13/14/15/17/21/26); the Tamagui $N ladder is what thousands of
call sites actually type; ten distinct sizes rendered. The ladder is precisely
why we do not edit the call sites — it is remapped once onto the declared
numbers, so every surface lands on the scale. Same for radius (four values:
6 control, 8 input/row, 12 panel, pill — the three spellings of "pill"
collapse to the $10 token) and spacing (the 4px ramp; $2/$3/$4 were landing on
7/13/18px, the three most-rendered paddings in the app).

The 16px leak was not the ladder: `body` never set a font-size, so everything
the ladder does not reach inherited the browser's 16px root. One declaration
in globals.css, sourced from --text-base, and the inherited size and the named
size finally agree.

ZERO ALL-CAPS. The hard rule. 26 `textTransform="uppercase"` sites deleted
across 15 files and 19 typed-in-caps strings re-cased — including GET STARTED,
which the audit missed and the gate caught. Where a label was carrying
hierarchy by shouting it gets it back the calm way: 11px, weight 500, muted.
Genuine acronyms (API, GPU, CIDR, …) are untouched and allow-listed.

THE Z LADDER (src/lib/z.ts). app/design/z.css has always declared it and was
read in zero places. Every literal now names a role instead: dropdown, modal,
popover, toast. Correcting the brief — @hanzo/brand 1.4.0 ships no --z-* at
all; the vendored z.css is the real ladder, so its numbers are used rather
than a third set invented to solve a problem about having too many sets.

PANEL + ROW (ui/Panel.tsx). The one primitive genuinely missing: a stack of
rounded panels whose rows are label + description left, control right. The
shared per-product Settings view is converted to it, which is every product's
Settings tab, and deletes a bespoke row in the process.

THE GATE (e2e/design-invariants.spec.ts). Asserts on computed style and
geometry, not source: zero uppercase and zero typed caps, membership in each
scale, every stacking layer from the ladder, overlays that actually paint and
sit on screen, WCAG contrast from the colours that painted, and no sideways
scroll at 1440 or 390. Two exemptions, both narrow and declared at the source:
a monogram scales with its circle (data-monogram) and chart axis text lives in
SVG. A rule that only lives in a review comes back.
2026-07-27 12:22:34 -07:00
zooqueen 702a5208cd feat(console): find and do — one persisted list view, pins that survive, a palette you can act in
Pin, sort, filter, search and act, as ONE mechanism each instead of three
duplicates and a broken write path. Every claim measured in a browser on
computed style and geometry (e2e/find-and-do.spec.ts, 7 tests).

BUG: every preference was lost on reload. Preferences treated the account as
authoritative for keys it had never mentioned, so each load replaced state AND
the write-through cache with the token's (empty) view — losing pins, pin groups,
product colours and open nav sections. The account now wins per key it CARRIES;
the cache fills the rest (preferences-core.mergePrefs, pure, tested).

ONE list view (src/lib/list): useList(id) persists a list's search, order and
facets under `list.<id>` in the same account store as pins. Its comparator,
reducer and predicate are promoted verbatim out of admin/infra's private copy,
which now re-exports them — one implementation, its 30 tests unchanged. `Filters`
is the one bar (search + facets + a Reset that exists only when something is
narrowed). Adopted by Models and Marketplace, dropping two bespoke search boxes.

Pins in search: pinnedFirst is the one "pinned leads" rule, shared by the sidebar
and the palette. Every result carries a right-edge pin — invisible until reached,
lit while pinned — and ⌥↵ pins the selection without closing.

BUG (introduced, then caught): pins must not outrank what you typed — floating
them over the ranked list made "billing" + ↵ open Models. Pins order the DEFAULT
view only; typing is decided by relevance. Locked by asserting where you land.

BUG: the resting pin painted at full strength — a plain .hz-pin lost to Gui's
compiled `:root ._ops-…` (0,2,0), then a broken CSS comment silently killed the
rule outright. Only the computed-style assertion caught either.

tsc clean; vitest 3121 passed (+35); find-and-do 7/7 with screenshots, incl.
4.5:1 contrast and zero horizontal body scroll at 390px. NOT verified on live
admin.hanzo.ai (auth-gated, no password typed).
2026-07-27 11:52:44 -07:00
zooqueen a0ed4d8729 feat(shell): one account control, both switchers, at the foot of the rail
The console had THREE places to answer "who am I and which org am I in": an org
switcher at the top of the sidebar, an account popover at the bottom, and a third
menu in the phone drawer — with four ways to sign out between them. They are now
one control, mounted where the CTO asked for it, and it is the shared
`@hanzo/iam` UserMenu rather than a fourth thing built here.

Deleted: OrgSwitcher.tsx (a wrapper around the retiring shadcn @hanzo/ui), the
account popover and its two private helpers, the drawer's own theme/profile/
sign-out rows, and the wallet's duplicate sign-out. The shell is 160 lines
lighter and the wallet is a wallet again.

REACH. The SDK's own org state reads the token's memberships claim, which cannot
express what an admin console does. `@hanzo/iam` 0.21.1 takes an optional
`findOrgs`, so the switcher searches the console's EXISTING lazy, server-paged
cross-tenant list — the same `IamAdminApi.organizations` the full-page picker
uses, gated to a super admin, unchanged. A regular user is never asked for it and
sees their own org exactly as before. The reach was extended in the SDK, not
forked here.

MONEY. `adminOrgState` is a pure adapter and passes `org-scope.switchOrg` BY
REFERENCE — the console's one switch, which persists the scope and reloads so
every module refetches under the new `X-Org-Id`. No second switch, no header, no
billing call is added, so the ledger rule sits exactly where it sat.
`org-state.test.ts` pins the identity so a second switch cannot creep in later,
and the render spec asserts the write to `hanzo.console.org` really is what a
selection produces.

Two z-indexes joined the ladder they were ignoring, because the account control
needs them: a SlideOver was pinned at a literal 1000 and the rail flyout at 1000,
both ABOVE the popover rung, so on a phone the menu opened inside the account
sheet and the sheet swallowed it — present, measurable, and unclickable. They are
now `--z-modal` and `--z-dropdown` from `app/design/z.css`, whose own comment
already said a popover anchored in a sheet paints over it. The remaining literals
(tour, toast, detail pane) are the shell lane's.

Proven by rendering, not by status codes: e2e/account-menu.spec.ts opens the
control in the real signed-in shell and measures an opaque background, Geist, a
body fully inside the viewport at 1440 AND 390, rows padded and at 4.5:1, a hover
state that actually differs, zero uppercase nodes, the menu hit-testing to itself
over the sheet — and finds "Acme Industrial", a tenant that is nobody's
membership. tsc clean; 3093 unit tests green; next build and build:embed green.
2026-07-27 11:47:54 -07:00
zooqueen 3899017e31 feat(console): ONE level-2 nav — the registry declares it, the sidebar renders it
Clicking into a product revealed its options twice: the sidebar drilled in and
rendered the product's sub-nav from the registry, and the module ALSO rendered a
private `const TABS` strip. The two lists were written independently and
disagreed — /models showed eight rows in the rail and four tabs in the content,
and they did not agree on what the index is called ("Overview" vs "Catalog").
Eight products declared no sub-pages at all, so their real tabs lived only in the
content strip and the rail hid them.

The registry is the one source now. `CatalogEntry.indexLabel` names a product's
own index where it is a named surface (Models → Catalog, Tasks → Workflows, Team
→ Members); the eight missing `subpages` sets are declared, and the icons the
strips carried moved onto the declarations. `components/ui/SubNav.tsx` renders
that same declaration for the viewports where the sidebar is a drawer and hides
itself at lg+ where DrillNav owns level 2 — one declaration, two mounts.

Level is the URL and nothing else: `activeSubpage` reads it back, `subpageHref`
writes one URL per screen, and `subpageSlug` validates a segment against the
declaration (so a hand-typed tab cannot light a view the module does not render,
and an admin-only sub-page is never offered to a customer). 18 modules lost their
`TABS` plus their bespoke TabButton/TabBar/nav/path helpers.

Functions had two indexes — its '' route was a living-overview while the module
carried an older OverviewTab reachable only via a bogus URL, and the index
therefore had no level-2 nav on a phone. One component owns the product at every
level now; the dead OverviewTab is deleted. /crm/companies was a duplicate URL
for the screen /crm already renders; the index IS Companies.

Render-proven (e2e/level-2-nav.spec.ts, 5/5): at 1440 the content strip's
computed display is none while the rail is drilled; at 390 the strip is the one
nav, lists the same labels, every tab has a painted box inside the viewport, and
the body does not scroll sideways; a reload of /models/blend lands on Blend; Back
moves the level without dropping the drill or the account-backed pins; and a
sweep asserts all 18 converted products paint no second nav.

vitest 3093 passed (+9). tsc adds zero errors — the one it reports
(src/lib/event.ts `dsn`) is pre-existing local dep drift and reproduces on a
clean origin/main tree.
2026-07-27 11:37:52 -07:00
hanzo-dev bcc1aab571 ci: drop the Gitea mirror-sync nudge
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.

One mechanism, in one place, instead of ~350 copies of a cron.
2026-07-27 10:28:14 -07:00
zooqueen 1e647907a0 fix(console): one paper, one leading — overlay elevation and display type (v8.5.32)
Hanzo CI/CD / cicd (push) Successful in 5m42s
CI/CD / cicd (push) Successful in 5m42s
Two rendering contracts were silently not applying. Both found by measuring
computed styles in a real browser, not by reading code.

The product-guide headline had a 1px line box. PitchHero set
`style={{ lineHeight: 1.12 }}` — a correct ratio in plain React, whose unitless
allow-list includes lineHeight. React Native Web's does not, so under @hanzo/gui
it compiled to `line-height: 1.12px`: a 30px/900 headline in a 1px box, a 29px
overflow that dropped its descenders into the subhead and clipped the GET STARTED
eyebrow. It now wears `hz-display`, the class this app already added for exactly
this (PublicLanding, v8.5.24) — one way, one rule, every token and breakpoint.
Measured after: 30px on 33px leading at desktop, clean two-line wrap at 390px.

e2e/leading.spec.ts pins the invariant rather than the call site: no visible text
node on /models, /agents or /playground may compute a line-height smaller than its
own font-size. It fails on the unfixed tree and catches the next numeric lineHeight
anyone writes without their knowing about RNW's allow-list.

No overlay was wearing the elevation ladder. Gui compiles its shadow props to an
atomic rule injected at runtime as `:root ._bxsh-…` — specificity (0,2,0). The
design-token utilities were plain `.hz-paper` (0,1,0) and lost, so the command
palette, app launcher, floating chat and three menus rendered Gui's
`0 12px 24px rgba(0,0,0,.33)` instead of ring + top highlight + --hz-elevation-3.
On the true-black canvas that shadow is nearly invisible — the sheets did not lift
off the page. The utilities are now `:root .hz-x.hz-x` (0,3,0): deterministic in
either stylesheet order, no !important.

And every anchored overlay now wears ONE surface. Eleven Popover.Content sites
passed Gui's `elevate` while three wore `hz-paper` — one concept, two depths, plus
the same bordered/bg/borderColor triple repeated fourteen times. All fourteen now
spread ~/components/ui/paper, which holds the surface, the token elevation and the
opacity-only hz-menu-in entrance in one place.

Verified by rendering: scope switcher, network picker, model selector, save-prompt
popover and the ⌘K palette all opaque, correctly anchored, ring visible, nothing
occluded. 3,086 unit tests pass; leading spec green.
2026-07-27 09:22:05 -07:00
hanzo-dev 147ecd3227 feat(admin): one DigitalOcean fleet board — inventory, fill, safe reclaim
admin.hanzo.ai could show what we BILL but not what we RUN. The nodes, the
volumes, the load balancers were visible only in the DO console, with no
cross-reference to what Kubernetes actually mounts — so "is this orphaned?" had no
answer anyone could act on. `/v1/admin/infra` (already live in cloud) is that
answer; this is its UI.

IT REPLACES A SECOND BOARD RATHER THAN JOINING IT. While this sat on a branch,
`block-storage` shipped: the same DO volumes, listed with fill % beside this one's
inventory of them. One noun, two boards. The two BACKENDS genuinely answer
different questions about the same object — /v1/admin/infra knows what is
REFERENCED (and therefore safe to delete), /v1/admin/block-storage knows how FULL
it is — so the volumes tab now reads both and shows ONE row with a Fill column.
The block-storage catalog entry and its module are retired; its client survives as
the fill source. Two reads for one row is fine. Two boards for one noun is not.

The delete control is deliberately timid. It shows WHY something is or is not
deletable rather than only whether, because the honest answer is usually "not", and
it defaults to snapshot-first. The server re-proves deletability from a FRESH
cross-cluster scan before acting, so the button is a request, never a verdict — a
client that lied would still be refused. That check is the reason this exists: the
naive "no k8s tag" test would have proposed deleting 4.39 TiB of live cluster data,
and one unreachable cluster freezes every deletion rather than degrading.

Sorting went into the SHARED DataTable, so every admin board gets it — opt-in per
column, caller-owned comparator, `aria-sort` on the headers.

Applied to current main rather than merged: the branch is 843 commits behind, and
the three conflicts were a fused registry entry, a `tracker` id belonging to an
unrelated commit on the same old branch, and an aggregate-head list that simply
wanted both heads.

Typecheck adds ZERO errors (29 before, 29 after — all pre-existing). 3058 tests
pass, up from 3022; the 2 failures are pre-existing on main.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 09:06:10 -07:00
hanzo-dev 1991033f47 refactor(api): call the ai resource surface at /v1/ai/<resource>
hanzoai/ai replaced ~213 flat compound routes with one namespaced REST surface
generated from a single table, and DELETED the old ones. These are the console's
callers.

    get-stores        -> GET    /v1/ai/stores
    get-store         -> GET    /v1/ai/stores/{owner}/{name}
    add-store         -> POST   /v1/ai/stores
    update-store      -> PATCH  /v1/ai/stores/{owner}/{name}
    delete-store      -> DELETE /v1/ai/stores/{owner}/{name}
    get-cloud-usages  -> GET    /v1/ai/usages/cloud
    get-providers     -> GET    /v1/ai/providers
    …

Applied fresh to main rather than rebased. The branch this was developed on is 843
commits behind, and replaying a stale UI diff across that much drift would have
been guesswork; the migration is small and mechanical, so it is re-derived against
what main actually calls today.

Two things beyond the renames.

The identity moved from a query param into the path, as TWO segments. Every object
is keyed by the pair (owner, name), so `?id=acme/my-store` becomes
`/ai/stores/acme/my-store` with each part encoded separately (`memberOf`). Encoding
the pair as ONE segment does not work — the server decodes %2F back into a
separator before routing, so it would never match. There is a test for exactly that.

Updates are PATCH and deletes are DELETE, so client.ts gains `patch`/`del` and the
`cloudPatch`/`cloudDelete` twins, mirroring the existing helpers.

CLOUD_HEADS drops eight individual ROUTES (get-stores, add-store, …) for one
SERVICE head: `ai`. That is what a head-based allow-list is supposed to mean; it
enumerated routes only because the surface had no namespace to enumerate.

That collapse would have quietly widened one thing, so it does not. The
cross-tenant store listing was explicitly refused before, and granting `ai` would
have admitted it — REFUSED_SUBPATHS keeps that one refusal, scoped to that path and
NOT a blanket rule over every `global` sub-path (the Providers board really does
read its cross-tenant catalog, so a blanket rule would break a live surface while
claiming to preserve a property that never covered it).

Left alone on purpose: projects.ts, team.ts and admin.ts go through iamList/iamOne,
which prefix `iam/` — those are the IAM service's own routes under its own naming.

Typecheck adds ZERO errors (29 before, 29 after — all pre-existing). 3021 tests
pass; the 3 failures are pre-existing on main (oss-apps, store/logic) or flaky
(invite AEAD, passes 5/5 in isolation).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:52:42 -07:00
hanzo-devandhanzo-dev 431efac48f ci(sync): run the mirror nudge on our own runners
This job has been red on every push, with no steps and no log, because it never
got scheduled: it asks for a GitHub-hosted runner, this repo is private, and
private repos bill hosted minutes against a limit the org has reached. Public
repos are unaffected, which is why the identical workflow in hanzoai/world is
green — the difference is billing, not the file.

It is one 20-second curl. Our own pool runs it for nothing and sits inside the
network it is calling.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:49:57 -07:00
hanzo-devandhanzo-dev 601e0f819c chore: trim the pnpm config comments to what they need to say
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:43:14 -07:00
hanzo-devandhanzo-dev 9451a61910 build(console): relock after merging @hanzo/event 0.3.4
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:17:27 -07:00
hanzo-dev f9272ec880 Merge remote-tracking branch 'origin/main' into chore/pnpm
# Conflicts:
#	package-lock.json
2026-07-27 08:15:06 -07:00
hanzo-devandhanzo-dev fc5fe3c616 build: move console to pnpm, one lockfile, one pinned package manager
npm could never run `npm ci` here. The Dockerfile said so in a comment: @hanzo/gui
pulls a react-native tree whose platform and optional packages resolve differently
between npm versions, so a lockfile written by one npm failed under another, and
the build ran `npm install` — resolving the tree fresh every time and using the
committed lockfile as a suggestion. A lockfile nobody installs from is decoration,
which is how it drifted far enough that `npm ci` was already dead on main before
this change (react-native-worklets missing from the tree it claimed to describe).

pnpm records every platform in the lockfile, so the build installs exactly what is
committed and fails loudly rather than quietly resolving something else. Both
Dockerfiles now `corepack enable && pnpm install --frozen-lockfile`, corepack takes
the version from `packageManager`, and package-lock.json is gone. .gitignore is
reversed accordingly: pnpm-lock.yaml is the tracked one and every other manager's
lockfile is ignored, so a stray `npm install` cannot leave a second source of truth.

pnpm-workspace.yaml carries the two settings this needs, both documented in place.
Install scripts are denied unless named, and esbuild and sharp are named because the
app does not build without them. The release-age gate is excluded by SCOPE for
@hanzo/* rather than by version, because pnpm rewrites a per-version entry on every
bump, and a file that rewrites itself during install makes --frozen-lockfile fail in
CI — the exact determinism this change exists to get.

Verified on pnpm 11.17.0: frozen-lockfile install clean, tsc 0 errors, 3056 tests
passing, `pnpm build` compiles, and `pnpm build:embed` emits the real bundle cloud
fail-hards on (367KB index.html, 5.5M _next). node_modules stays self-contained —
every symlink is relative into node_modules/.pnpm — so the runner stage's
COPY --from=build of node_modules still resolves.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 08:13:46 -07:00
hanzo-dev fcdd98a80a event: 0.3.4 — the console error plane now resolves its own DSN
0.3.4 carries the product -> DSN registry, so `product: 'console'` resolves the
hanzo-console project with no env var and no build argument. This is the release
that actually makes console.hanzo.ai report errors.

Lockfile edited SURGICALLY (4 lines: the range, version, resolved, integrity).
Do NOT regenerate it here: both `npm install` and `npm install --package-lock-only`
drop the optional-peer block `node_modules/expo/node_modules/react-native-worklets`,
which `npm ci` then needs — that removal is what broke CI once already and had to be
restored by hand. npm ci verified green against this lockfile.

tsc clean; 3048 tests pass.
2026-07-27 08:13:45 -07:00
hanzo-dev 0681460869 ci: nudge git.hanzo.ai to pull on push
git.hanzo.ai mirrors this repo by PULL on a ~10-minute interval, and arcd runs
CI/CD there — so every push waited out that interval before anything built.
This asks Gitea to pull HEAD immediately.

Latency only: the repo already mirrors via the App webhook, so a missing
HANZO_GIT_TOKEN or a failed curl is non-fatal and never fails the push.
Idempotent (mirror-sync just pulls HEAD) and concurrency-coalesced.
2026-07-27 08:01:12 -07:00
hanzo-devandhanzo-dev c5134dd89f docs: stop describing the app launcher as a surface of its own
Apps and command search are one surface now — the launcher was folded into the
palette and its component is gone. The prose did not follow: thirty-seven places
still listed "nav/launcher/palette" as three things to gate, and CommandPalette
still named a launcher hook among the chrome hooks it composes, which no longer
exists.

Read literally, those comments send someone looking for a second surface to keep
in sync with this one. There isn't a second surface; that was the point of the
change.

Comments only — every changed line is inside a comment, tsc is clean, the suite is
3056 passing, and next build compiles.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 07:50:54 -07:00
hanzo-devandhanzo-dev 30d620ed1e fix(deps): restore the one lockfile entry that made npm ci fail
npm ci refused to install at all:

  npm error Missing: react-native-worklets@0.8.3 from lock file

expo depends on it, but the nested entry was absent from package-lock.json, so
the lockfile no longer described a complete tree and the reproducible-install
path was dead for everyone.

Image builds were unaffected, which is why this went unnoticed: the Dockerfile
runs npm install, which re-resolves and papers over the gap. Only npm ci — the
one command that installs exactly what is written down — could see it.

npm install writes back exactly the missing entry: 28 lines, one package added,
none removed, no version moved. npm ci now completes; tsc is clean, the suite is
3056 passing, and next build compiles.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 23:40:15 -07:00
hanzo-dev cbcf2466a6 event: wire the error plane (0.3.1 -> 0.3.3 + dsn)
The console reported ZERO errors. Two independent reasons, both silent:

  1. @hanzo/event was pinned ^0.3.1 — the version with NO envelope code at
     all. captureError() collected and dropped.
  2. createAnalytics() was never passed a dsn. The error plane authenticates
     independently of the event stream; with no dsn it is inert by design
     (fail-safe), so even 0.3.3 would have stayed dark.

This file also asserted the failure into existence: it documented /v1/event as
being lensed server-side into 'error tracking'. There is no such fan-out. That
claim is why nobody looked. Corrected to state the two-plane reality and the
no-dsn => inert contract explicitly.

dsn comes from NEXT_PUBLIC_HANZO_EVENT_DSN, the convention hanzoai/app and
hanzoai/hanzo.ai already use. Publishable by design — it ships in the bundle.

Still dark until a DSN is set: NEXT_PUBLIC_HANZO_EVENT_DSN is set on zero CRs
fleet-wide, and no 'console' Sentry project exists yet
(/v1/sentry/console/envelope/ -> 404). This makes the surface correct so the
plane lights up on config alone, with no further code change.

tsc clean; 3048 tests pass.
2026-07-26 23:35:31 -07:00
hanzo-devandhanzo-dev 8f011bea82 Merge the forge into GitHub — converge the two mains
Same drift as cloud and iam: git.hanzo.ai held 133 commits GitHub did not, so the
sync could never fast-forward. Merging the forge in from this side needs no forge
credentials and makes that push a fast-forward again.

Two files conflicted, and both resolve to GitHub's side because GitHub is the newer
one: parseBlueprint and fetchOssApps were lifted into @hanzo/ui/oss and are
re-exported from here, while the forge still carries the inline copies. Taking the
forge's would put a second implementation of the compose reader back in the tree.

The merge also brings the forge's "unify Apps and command search" refactor, which
deletes AppLauncher and folds it into CommandPalette. That deletion arrives with the
CommandPalette and dashboard changes that stop calling useAppLauncher, so nothing is
left dangling — the merged tree has no reference to it.

tsc reports the same 24 errors as clean main and vitest the same 3028 passing with
the same 2 files failing to collect; both are the stale local @hanzo/ui (8.0.8
installed, ^8.0.11 required, which is where the /oss subpath lives). The merge adds
nothing to either count.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 23:02:53 -07:00
hanzo-dev 9059e1c361 docs(o11y): the AI lens is ours — say o11y AI, not Langfuse
Two comments described the admin o11y board's generation stats as Langfuse's.
The backend surface is renamed to o11y_ai (hanzoai/cloud), and Langfuse is a
product we do not run; naming our own lens after it makes a reader carry that
history for nothing.
2026-07-26 22:56:08 -07:00
hanzo-dev 0fdea766d1 feat(store): an app detail page, on the ONE shared OSS module
Restores the App Store detail work (lost when main was reset past it) and, this
time, builds it on @hanzo/ui/oss instead of a local copy.

Clicking a card opened nothing; the only affordance was Deploy, so choosing what
to run in your own cloud was a decision made from a 300px tile. Cards now open
/store/:id, which answers what actually decides it — which containers start,
which images they pull, what ports they publish, what configuration they expect
— read from the blueprint's own docker-compose.yml, not restated from the tile.
Env KEYS only; the values are routinely secrets.

The catalog shape, normalizer, URL builders and compose reader no longer live
here. They are @hanzo/ui/oss (v8.0.11), shared with platform and the public
oss.hanzo.ai gallery — three copies of one format collapsed to one, verified
against the real corpus (400/400 blueprints parse, 812 services). This module
keeps only what is genuinely console-specific: claimPath, which names a console
route. The import paths are unchanged, so no call site moved.

The maker payout hook repeated on all ~1030 tiles; it now appears once in the
banner, plus on the detail page of an app you are actually looking at. Reworded
off "Built one of these?", which conceded we built none of it. Authors moves
Web3 → Dev: the audience is open-source developers, and a wallet is how the
payout arrives, not what the product is about.
2026-07-26 20:34:50 -07:00
hanzo-devandhanzo-dev cff833e0a2 chore: ignore the lockfiles npm does not write
package-lock.json is the tracked lockfile and the Dockerfile runs npm install,
so a pnpm or yarn lockfile left by a stray install is drift no one reads. Also
recovered from the rescue lineage, where it was written and never carried over.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 20:31:12 -07:00
hanzo-devandhanzo-dev 0d68a6277f feat(guide): land the getting-started guide module off the rescue branch
The guide work — the pitch hero, the product guide panel, the signal/spec/
registry/guard layer under lib/guide, and the chat, suggest and budget surface
on GuideModule — was written on the local main that predates the history
rewrite. That lineage survives only as origin/rescue/console-local-main, which
shares no ancestor with main, so nothing carried it across: of its 127 commits,
patch-id comparison finds 9 with no equivalent here, and this module is what
they add.

Taken as content rather than merged, because a merge has no base to work from
and a whole-tree diff would drag main backwards: main is newer on @hanzo/ui,
@hanzogui/shell, the sidebar workspace layout, BrandLogo, OrgSwitcher and two
e2e specs the rescue lineage never had. Only the additive side is here.

The eleven new files are additions main has nowhere. The five touched files
take the rescue version whole after confirming each one's diff is additive —
api/guide.ts is +104/-0, and GuideModule and guide/logic.ts each report one
deletion that is an import line being extended. dashboard.tsx is the exception
and got the three guide hunks by hand, leaving main's SidebarWorkspace alone.

Left behind deliberately: the upstream-attribution removal in
products/registry.tsx. Dropping an attribution field documented as carrying
license compliance is a decision of its own, not something to fold into landing
a feature.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-26 20:30:51 -07:00
hanzo-dev 3bcc8ec3ed console: landing, analytics module, and theme refresh
(cherry picked from commit 6318b90170f25972a0caec2136889762965eff30)
2026-07-26 20:10:16 -07:00
zeekay b286d48c7f fix(chrome): the console wears the ORG's identity, and the switcher is the account control's peer
Two defects in the cloud console's chrome, both fixed in the SHARED control
(@hanzo/ui 8.0.11) so every surface inherits them, not just this one.

The top-left mark showed the house H whenever an org had set no logo — a
customer's console showing OUR brand. It now renders `OrgMark` unconditionally:
the org's own logo when IAM carries one, else the org's MONOGRAM, the treatment
the account widget already gives a person. Never the house glyph, never the org
name as running text.

The org switcher was a caption beside a control. Its trigger is now the peer of
the account row — 44px tall, a 30px mark, the same type, the same hit area — and
`SidebarWorkspace` is a COLUMN so it stretches the sidebar's width the way the
account row does (a row container had shrunk it to its text).

One org-identity source: `useOrgLogo` (a URL) becomes `useOrgIdentity` (name,
display name, logo — one cached read), fed to BOTH the mark and the switcher's
new `current` prop, so the two slots can never disagree and a user with no
cross-tenant list still gets their own logo. The dead `BrandLogo` component,
a second copy of the same logo-else-mark decision, is gone.

`e2e/org-identity.spec.ts` measures it off the rendered boxes: without the change
the mark paints an SVG with no monogram and the switcher has no trigger to find.
2026-07-26 19:32:06 -07:00
hanzo-dev 07c1982b02 console: record why the datastore connect command names clickhouse-client
The datastore connect command is the one string on this page a user copies and
runs on their own machine, so it has to name a binary they can install. Upstream
clickhouse-client is installable and speaks the unchanged native protocol, so it
connects to a datastore instance as-is.

Our own datastore-client exists as a package definition (hanzoai/datastore
packages/datastore-client.yaml installs /usr/bin/datastore-client) but has no
serving repo: packages.hanzo.ai/{,deb/,rpm/stable/} all return 404. Renaming the
string today would print a command that cannot be installed.

The comment records the condition to flip it, so the next naming pass does not
change it blind.
2026-07-26 19:19:08 -07:00
zeekay 3b832c1a78 feat(store): an app detail page, and say the payout line once
Clicking a card opened nothing; the only affordance was Deploy, so choosing
what to run in your own cloud was a decision made from a 300px tile. Cards now
open /store/:id, which answers what actually decides it — which containers
start, which images they pull, what ports they publish, what configuration
they expect — read from the blueprint's own docker-compose.yml rather than
restated from the tile.

parseBlueprint is a small structural reader, not a YAML implementation: it
walks the services block by indentation and pulls only the four keys the page
shows. Pure and total, so it is unit-tested and safe on untrusted CDN content.
What it cannot read is absent, never guessed — a blueprint without compose says
so instead of rendering an empty table that implies nothing starts. Env KEYS
only; the values are frequently secrets.

The maker payout hook repeated on all ~1030 tiles. Saying it 1000 times is not
1000 times more persuasive, so it now lives once in the banner (plus the detail
page of an app you are actually looking at). Reworded off "Built one of these?",
which conceded we built none of it and framed our own store as other people's
work — the subject is the reader's project and what they earn from it.

Authors moves Web3 → Dev: the audience is open-source developers shipping code.
A wallet is how the payout arrives, not what the product is about; filing it
under Web3 hid it from everyone it is meant for.
2026-07-26 17:26:32 -07:00
hanzo-dev b55eb201d6 WIP on main: 9d6190a78 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:05:19 -07:00
hanzo-dev e7b36af216 index on main: 9d6190a78 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:05:19 -07:00
hanzo-dev 9d6190a783 merge: console enso leaderboard + reported-vs-measured source toggle 2026-07-21 15:04:25 -07:00
594 changed files with 27809 additions and 13166 deletions
+5 -3
View File
@@ -3,10 +3,12 @@ node_modules
out/
dist/
# npm is the one package manager here — package-lock.json is the tracked lockfile
# (the Dockerfile runs `npm install`). A pnpm/yarn lockfile would silently drift.
pnpm-lock.yaml
# pnpm is the one package manager here — pnpm-lock.yaml is the tracked lockfile and
# `packageManager` in package.json pins the version corepack installs. A lockfile
# from any other manager is a second source of truth that silently drifts.
package-lock.json
yarn.lock
bun.lockb
# build artifacts
*.tsbuildinfo
+1 -1
View File
@@ -21,5 +21,5 @@ on:
pull_request:
jobs:
cicd:
uses: hanzoai/ci/.hanzo/workflows/build.yml@v2
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
secrets: inherit
-53
View File
@@ -1,53 +0,0 @@
name: Sync from GitHub
# git.hanzo.ai is canonical; development also lands on
# github.com/hanzoai/console. ONE deterministic direction: an in-cluster PULL.
# The runner reaches both ends (GitHub outbound, this Gitea via the instance URL
# actions/checkout already uses), so the sync has no ingress dependency.
#
# Fast-forward ONLY: a divergence fails loudly here instead of force-pushing
# either side.
#
# Inert until hanzoai/console stops being a Gitea pull mirror — while it is one,
# the forge overwrites main from GitHub on its own timer and rejects the push
# below. That conversion is also what turns Actions on here (measured today:
# mirror: true, has_actions: false ⇒ zero native runs).
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch: {}
concurrency:
group: sync-from-github
cancel-in-progress: false
jobs:
ff-main:
runs-on: [hanzo-build-linux-amd64]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: true
- name: Fast-forward main from github.com/hanzoai/console
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/console.git" main
LOCAL="$(git rev-parse HEAD)"; REMOTE="$(git rev-parse FETCH_HEAD)"
if [ "$LOCAL" = "$REMOTE" ]; then echo "in sync at $LOCAL"; exit 0; fi
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
echo "fast-forwarding $LOCAL -> $REMOTE"
git push origin "$REMOTE:refs/heads/main"
# A push made with the workflow token does not trigger workflows, so
# synced commits would never build. Dispatch CI explicitly.
curl -fsS --max-time 20 -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/cicd.yml/dispatches" \
-d '{"ref":"main"}' \
&& echo "CI dispatched" || echo "CI dispatch failed (non-fatal — next direct push builds)"
elif git merge-base --is-ancestor "$REMOTE" "$LOCAL"; then
echo "canonical is AHEAD of GitHub — nothing to pull (never force from here)."
else
echo "::error::main DIVERGED between GitHub ($REMOTE) and canonical ($LOCAL) — refusing to force. Reconcile manually."
exit 1
fi
+50 -44
View File
@@ -1,45 +1,51 @@
# console2Hanzo Cloud Console (Next.js 15 + @hanzo/gui). BSD-3-Clause.
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
FROM public.ecr.aws/docker/library/node:24-alpine AS build
WORKDIR /app
# Exact commit for a deterministic Next build id (next.config.mjs generateBuildId).
# The alpine image has no git binary, so CI passes the SHA as a build arg -> ENV,
# baked into .next/BUILD_ID so every replica of this image shares ONE build id.
ARG SOURCE_COMMIT=""
ENV SOURCE_COMMIT=$SOURCE_COMMIT
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
# a `COPY` that FOLLOWS `RUN npm install` in the same stage drops the RUN's freshly
# created node_modules (the 'next not found' cause — the install's own `test -f next`
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
# before install means node_modules is created by the LAST RUNs and nothing clobbers
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
COPY . .
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
RUN mkdir -p public
# npm install (not ci): @hanzo/gui pulls a react-native dep tree whose platform/
# optional packages resolve differently across npm versions, so a lockfile generated
# by one npm fails `npm ci` under another. install reconciles the tree for the
# build platform; retry-hardened against registry throttling.
RUN npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
# (exit 137); cap the heap generously (chat uses 4096).
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
RUN npm run build
# hanzoai/console — the console image. It serves itself.
#
# The console is a static SPA export; this puts hanzoai/static in front of it. That
# itself: hanzoai/static in front of the bundle. It exists so a console change
# can reach production without a cloud release.
#
# Today console.hanzo.ai is answered by the cloud binary, which go:embeds the
# bundle (webui/console.go `//go:embed all:dist`). That couples a frontend change
# to a backend release: the bundle must be published, its tag pinned in cloud's
# Dockerfile, and a whole cloud image rebuilt and rolled out. The pin commit that
# preceded this one says what that costs — "four changes that could not reach
# production".
#
# Nothing about the request path changes when this serves instead. The embedded
# console is already a static export talking to the SAME origin's /v1, and cloud's
# catch-all only ever answered paths that no API route claimed (its apiPrefixes
# list is exactly "/v1/", "/api/", "/zap", "/healthz", "/readyz"). So the split is
# the one the ingress already expresses for admin.lux.cloud: /v1 + /zap to cloud,
# everything else here. Same bytes, same origin, same cookie — one fewer release
# in the way.
#
# -spa, not a 404 page: every unknown path IS a client-side route for an app shell
# (/models, /billing/budgets, a deep link someone pasted). The marketing site takes
# the opposite setting for the opposite reason — there a miss is a mistake.
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /app/.next ./.next
COPY --from=build /app/public ./public
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/next.config.mjs ./next.config.mjs
# next.config.mjs imports this at load time (build AND standalone runtime); copy it or the server ERR_MODULE_NOT_FOUND-crashes on boot.
COPY --from=build /app/src/config/build-id.mjs ./src/config/build-id.mjs
USER app
EXPOSE 4000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
FROM public.ecr.aws/docker/library/node:24-alpine AS build
RUN apk add --no-cache git
WORKDIR /console
# Heap headroom so the full @hanzo/gui static export never OOMs into a stub; telemetry off.
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
# The console.hanzo.ai analytics property (public per-site id, not a KMS secret) —
# the same default Dockerfile.embed bakes, so a bundle served from here reports
# identically to one served from inside cloud.
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile
# FAIL-HARD: the export MUST emit a real bundle, never a placeholder shell. An
# empty index.html would serve a blank page on every route with a 200, which is
# indistinguishable from a working deploy until someone opens it.
RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
&& echo ">> servable REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
# hanzoai/static, digest-pinned: a base image is pinned by digest so the bytes
# cannot change under a rebuild. (The console's OWN release is named by semver in
# the values file — that is the version a human reads.)
FROM ghcr.io/hanzoai/static@sha256:346ad30dc7f762c508b4467c2801b3d7e9ec201ec9b257bc7a38b60d59cecc05
COPY --from=build /console/out/ /srv/
EXPOSE 3000
ENTRYPOINT ["/static"]
CMD ["-root=/srv", "-spa", "-port=3000"]
+4 -4
View File
@@ -1,9 +1,9 @@
# hanzoai/console — the EMBED artifact.
#
# Builds the console SPA static export (`npm run build:embed` → out/) ONCE, as a
# Builds the console SPA static export (`pnpm build:embed` → out/) ONCE, as a
# versioned immutable image whose rootfs is just the bundle at /dist. hanzoai/cloud
# then does `FROM registry.hanzo.ai/hanzoai/console-embed:<ver> AS console` +
# `COPY --from=console /dist/ webui/dist/` instead of re-running npm+Next export on
# `COPY --from=console /dist/ webui/dist/` instead of re-running the install+Next export on
# EVERY cloud release (the ~15-min cache-busted long pole). Console changes far less
# often than cloud ships, so this moves the build to console's own cadence and turns
# a cloud rebuild into a registry pull.
@@ -21,10 +21,10 @@ ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
COPY . .
RUN npm install --no-audit --no-fund --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-timeout=120000
RUN corepack enable && pnpm install --frozen-lockfile
# FAIL-HARD: the export MUST emit a real bundle (non-empty out/index.html + out/_next/),
# never a placeholder shell — same invariant cloud's console stage enforced.
RUN npm run build:embed && [ -s out/index.html ] && [ -d out/_next ] \
RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
&& echo ">> embedded REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
FROM scratch
COPY --from=build /console/out/ /dist/
+10 -38
View File
@@ -1,42 +1,14 @@
BSD 3-Clause License
Licensed under either of
Copyright (c) 2026-present, Hanzo AI, Inc.
* Apache License, Version 2.0 (LICENSE-APACHE or
https://www.apache.org/licenses/LICENSE-2.0)
* MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
Portions of this software are derived from upstream code originally licensed under
the MIT License, with the following copyright notices retained per its terms:
at your option.
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Copyright (c) 2022 WorkOS
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual licensed as above, without any additional terms or
conditions.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
See HIP-0137 (hanzoai/hips) for the standard this follows.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+33
View File
@@ -0,0 +1,33 @@
MIT License
Copyright (c) 2026-present, Hanzo AI, Inc.
Portions of this software are derived from upstream code originally licensed
under the MIT License, with the following copyright notices retained per its
terms:
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Copyright (c) 2022 WorkOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+442 -34
View File
@@ -1,7 +1,7 @@
# console2 — Hanzo Cloud Console
Unified admin console for **Hanzo Cloud** and all cloud products. Our code,
BSD-3-Clause, built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
`MIT OR Apache-2.0` (HIP-0137), built on **@hanzo/gui** (the Tamagui-based cross-platform UI).
NOT an observability-console fork, NOT casibase — it is a clean client over the unified `/v1`
backend (`hanzoai/cloud`), reached at the ONE Hanzo API endpoint https://api.hanzo.ai/v1/*.
@@ -92,7 +92,8 @@ keyed by `owner/modelName`, so modelName is form-entered, not generated).
One `request()` in `lib/api/client.ts`: always `credentials: 'include'` (the
backend sets a session cookie at `/v1/signin`), forwards `Accept-Language`,
unwraps the casibase `{ status, msg, data, data2 }` envelope, throws typed
unwraps the casibase `{ status, msg, data, total }` envelope (named `total`
first, legacy `data2` count accepted until the emitters finish renaming), throws typed
`ApiError` (401/403 carry status). Base URL = `config.cloudUrl` (default
`https://cloud.hanzo.ai`, override `NEXT_PUBLIC_CLOUD_URL`).
@@ -290,11 +291,18 @@ Findings + fixes (all in console2; honest states everywhere, no fakes):
separately) → honest "not available on this deployment" (was a scary error).
HUSD balance/top-up already honest "coming" (token unconfigured).
- **Providers was broken**`ProviderListView`/`ProviderEditView` imported the
ZAP twin (`~/lib/zap`), but the cloud `/zap` WS face is NOT served (the edge
returns SPA HTML, 200 not a WS upgrade — documented in `lib/zap/client.ts`), so
the module showed "Failed to load providers". Switched both back to the working
REST `~/lib/api` (identical surface). The ZAP twin stays as the proof-of-pattern
until `/zap` is bound. Providers now shows real/empty over REST like every module.
ZAP twin (`~/lib/zap`), and at the time the cloud `/zap` WS face was not served
(the edge returned SPA HTML, 200 rather than a WS upgrade), so the module showed
"Failed to load providers". Switched both back to the working REST `~/lib/api`
(identical surface). Providers now shows real/empty over REST like every module.
**STALE AS OF 2026-07-28 — `/zap` IS served.** Measured on all three hosts
(api.hanzo.ai, platform.hanzo.ai, cloud.hanzo.ai): a WebSocket upgrade handshake
returns **401**, not SPA HTML and not 200. A route that refuses an
unauthenticated upgrade is a route that exists. The reason this section gives
for preferring REST no longer holds, and read as current it says ZAP is
unavailable when it is merely gated. The REST path is still correct and still
shipping — this is a stale rationale, not a bug. Re-measure before acting on it.
- Already-correct honest states (unchanged): IAM/Audit + KMS/Secrets (`/v1/iam`,
`/v1/kms` 404 → "not available on this deployment"); Observability (`/v1/o11y`
503 → "runtime not initialized"). Plans/Embeddings show real data; Models/
@@ -318,14 +326,14 @@ keep all credentials server-side (the browser only ever sends its session cookie
embeddings|rerank` (not a general tunnel). `playground.ts` now points at this
proxy (`<origin>/ai`), so Models/Playground/Chat/cmd+K all work with no key in
the browser and no rotation on a chat turn.
- **`app/keys/route.ts`** — per-user `hk-` Cloud API key. POST mint/rotate, DELETE
- **`app/keys/route.ts`** — per-user `sk-` Cloud API key. POST mint/rotate, DELETE
revoke, GET status (no secret). Same app-on-behalf pattern via
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `hk-` secret is shown
`/v1/iam/mint-user-keys` + `/v1/iam/revoke-user-keys`. The `sk-` secret is shown
ONCE (POST). `ApiKeysModule` is now create/copy/rotate/revoke.
- Shared trust boundary: `src/lib/server/identity.ts` (server-only) — `resolveUser`
+ `mintUserKey`/`revokeUserKey`/`issueUserToken`. The `hanzo-console` client is
allow-listed in IAM `IAM_KEY_MINT_ALLOWED_APPS`; verified end-to-end that a
minted `hk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
minted `sk-` key and an issued user JWT both 200 on `api.hanzo.ai/v1/chat/
completions`.
- **Chat is interactive** (`chat/ChatConversation.tsx`): a real multi-turn
conversation over `AiApi.chat` (→ the `/ai` proxy), with a Zen default model,
@@ -1400,7 +1408,7 @@ of session-only reads (get-account, get-cloud-usages) — so it is KEPT, not rep
The fix is **additive, one session manager, zero regression** (worst case === v8.4.28):
- **`src/lib/server/session.ts`** — THE token manager (server-only by construction:
`node:crypto` + `next/server`). Sealed AES-256-GCM (key = HKDF(`IAM_MINT_CLIENT_SECRET`);
no-secret → per-process random key, never a constant). Casdoor tokens are ~3.6 KB
no-secret → per-process random key, never a constant). IAM tokens are ~3.6 KB
full-user JWTs (86 claims incl. password hash / TOTP secret) — the ACCESS token and
the REFRESH token are BOTH that big — so a single cookie is impossible (browser ~4 KB
per-cookie cap; a real browser silently REJECTS an oversized cookie — a bug curl never
@@ -1463,7 +1471,7 @@ for wrong application (client_id)":
`/v1/iam/signin`, which the ingress routes to the CLOUD backend (casibase); casibase
redeems with ITS confidential `hanzo-cloud` client → mismatch. Now the console redeems
the code ITSELF: on an admin host `iam-login.ts` authorizes with **PKCE** (S256
`codeChallenge` in the login body — casdoor stores it with the code), and
`codeChallenge` in the login body — IAM stores it with the code), and
`completeSignIn` posts `{code, codeVerifier}` to the new BFF **`app/auth/signin`**, which
runs `pkceCodeGrant(client_id=admin-console, code, code_verifier)` with **NO client secret**.
Verified in IAM source (`object/token_oauth.go` GetAuthorizationCodeToken 880-896): an
@@ -1475,7 +1483,7 @@ for wrong application (client_id)":
- **`durableSessionClientId(host)`** (session.ts) is the ONE host→client decision:
admin host → `admin-console` (public — pkceCodeGrant + secretless refreshGrant), else null
→ the confidential `hanzo-console` path. `/auth/refresh` uses it so an admin session
refreshes with admin-console (casdoor skips the secret check when it's empty, token.go 469).
refreshes with admin-console (IAM skips the secret check when it's empty, token.go 469).
- The admin session rests on `hz_session` (the code grant returns access + refresh, minted
at authorize time), which `resolveUser`/`getAdminGate` read FIRST — so the admin console
works without the casibase cookie. `accountOf` + `applyCookies` extracted to session.ts
@@ -1503,7 +1511,7 @@ noun was wrong: bots and machines are distinct compute kinds, not one "fleet").
global-admin only) BEFORE forwarding a minted user bearer. Same RED-H1 gate as
Business/Finance — no new proxy/trust boundary.
- **`lib/api/admin-compute.ts` — kind-parameterized client + pure tree.** OPTIONAL-SAFE
over BOTH shapes: pre-aggregated `{ leaves }` (the cheap ClickHouse GROUP BY) OR raw
over BOTH shapes: pre-aggregated `{ leaves }` (the cheap datastore GROUP BY) OR raw
`{ events }` (the coordinated 9-column datastore row: `org, app, project, kind, event,
machine_id, size, price_cents, ts`), which pure `foldEvents` folds client-side.
`normalizeCompute(raw, kind)` filters to the requested kind (defensive — the endpoint
@@ -1573,7 +1581,7 @@ backend's real tenancy model (RED-checkable).
`overview | timeseries | top | health` (analytics.go:95-98) with DIFFERENT response
shapes — so 5 tabs 404'd and 2 mis-parsed. `analytics.ts` + `AnalyticsModule.tsx`
rewritten to the real structs (`clients/analytics/query.go`): the **LLM lens is REAL
live per-org data** (`hanzo.cloud_usage`, prod ClickHouse `datastore.hanzo.svc:9000`),
live per-org data** (`hanzo.cloud_usage`, prod Hanzo Datastore `datastore.hanzo.svc:9000`),
charted over time; the **web + commerce lenses render honest-empty via the backend
`available` flag** (`hanzo.events`, until a collector emits) — never fabricated zeros.
Dropped the fabricated **Real-Time tab** (no realtime backend exists). Tabs: Overview
@@ -2957,25 +2965,34 @@ contract. The law: a client-facing same-origin API path is `/v1/<head>/…` —
`/v1/<head>` forms); `next build` ✓ (route table); `npm run build:embed` ✓ (go:embed gate,
31 handlers stashed+restored). `git grep -oE '/[a-z-]+/v[0-9]/'` = external hosts only.
## TODO — embed the Hanzo Social dashboard (follow-up to social.hanzo.ai)
## Social — one surface, shared parts (social.hanzo.ai)
The dedicated Social frontend shipped at **social.hanzo.ai** (hanzoai/social
`apps/frontend`, image `ghcr.io/hanzoai/social-frontend`) on the unified cloud BE
(`/v1/social/*` + `/v1/marketing/*`). Its pure display components were extracted to
**`@hanzo/ui@8.0.2`** under `@hanzo/ui/product/social` — `ChannelBadge`, `PostCard`,
`CampaignCard` (on `@hanzo/gui` primitives, data + handlers injected via props,
reusing `StatusTag`). To embed the same dashboard here:
- Bump `@hanzo/ui` to `>=8.0.2` and import `{ PostCard, CampaignCard, ChannelBadge }
from '@hanzo/ui/product/social'` — the console already has the `@hanzo/gui`
runtime/provider, so these render directly (unlike the Tailwind `apps/frontend`,
which is why the round-trip back into that app is intentionally NOT done).
- Data layer: call the same cloud shapes the social FE uses
(`GET/POST /v1/social/posts`, `/v1/social/summary`, `/v1/marketing/campaigns`);
org comes from the console's existing IAM session, not a new client.
- Still to extract into `@hanzo/ui` when needed: `QueueBoard` (compose
`@hanzo/data` `BoardView`), `Calendar` (reuse `@hanzo/data` `Calendar`),
`PostComposer` (needs `@hanzo/gui` input/select primitives). Ships via the normal
cloud release embedding `console@main` — coordinate with the cloud train.
**social.hanzo.ai IS this console**, booted in the `social`-only product shell
(`PRODUCT_SHELLS.social``config.isSocialHost`/`socialOnly``SocialModule`) over
the folded `/v1/social` in the cloud binary. The standalone `social-frontend` pod was
retired by the ingress cutover (`universe` `routes.yaml`: `/v1`+`/healthz`
`cloud-api-hanzo-ai` at prio 100, root → `console-hanzo-ai`) — there is deliberately
NO second dedicated app, and login rides the console's own `hanzo-cloud` path.
The presentational half lives ONCE in **`@hanzo/ui/product/social`**: `ChannelBadge`,
`PostCard`, `CampaignCard`, plus `SocialSummaryBar`, `ViewToggle`, `PostAgenda`,
`PostComposer`, `ProviderReadinessList` and the pure `format` module
(`formatPostTime`/`postDayBucket`/`postPreview`/`parsePostTime`). Data and handlers are
injected — `SocialApi` and every failure classification stay here.
- **Blocked on a publish**: those parts are in the `@hanzo/ui` source at **8.0.12** but
the published **8.0.11** never shipped a `product/social` — so `SocialModule` still
renders its local copies. `@hanzo/ui`'s own `pnpm run build` fails first: `src/index.ts`
re-exports the local `./backends/shadcn`, whose `clsx`/`tailwind-merge`/
`class-variance-authority`/`@radix-ui/*` imports are declared nowhere (the package
ships `@hanzo/ui-shadcn` as a *peer* instead). Fix that entry, publish 8.0.12, then
switch `SocialModule`'s social imports to `@hanzo/ui/product` and drop the local ones.
- **No invented endpoints.** Cloud `clients/social` serves only summary, providers,
accounts CRUD, posts CRUD, `posts/:id/publish` — there is no OAuth callback, no
analytics, no media upload, no teams. Publishing fails CLOSED with the exact missing
provider env vars; the composer surfaces that verbatim, never a fake "connected".
- `Post.media` round-trips (cloud always serializes an array and its PUT rebuilds the
row from the body, so dropping it would wipe a post's media).
## Inference Router — per-org router policy editor over /v1-first paths (v8.4.136)
@@ -3287,7 +3304,7 @@ so it rides the `app/admin/aggregate` BFF standalone and cloud-native in the go:
(`clients/admin/storage.go`, SuperAdmin `s.guard`) — the DO block-storage inventory
(count · total · monthly cost · per-volume region + attachment) from a new paginated
`Volumes()` on the existing `DO_API_TOKEN` client, PLUS the datastore's own fill from
ClickHouse `system.disks` (the 200Gi PVC) over the SAME shared `aiobject.DatastoreQuery`
`system.disks` (the 200Gi PVC) over the SAME shared `aiobject.DatastoreQuery`
the analytics/compute lenses use. Each source degrades independently; near-full raises
an alert (warn ≥ 80%, critical ≥ 90%). Pure `buildStorageSnapshot`/`alertLevel`/
`datastoreFillFromRow` unit-tested (4 Go tests green).
@@ -3513,3 +3530,394 @@ inferred:
after v8.5.30, preserving the unified app search and category accents.
- Verified the combined tree: strict typecheck, 3,044 tests, and the production
Next.js build all pass.
## One paper, one leading — overlay elevation and display type (v8.5.32)
Two rendering contracts were silently not applying. Both were found by measuring
computed styles in a real browser, not by reading code.
**The product-guide headline had a 1px line box.** `PitchHero` set
`style={{ lineHeight: 1.12 }}` — a correct, idiomatic ratio in plain React, because
React DOM's unitless allow-list includes `lineHeight`. React Native Web's does NOT
(`StyleSheet/compiler/unitlessNumbers.js`), so under @hanzo/gui it compiled to
`line-height: 1.12px`: a 30px/900 headline in a 1px box, a 29px overflow that dropped
its descenders into the subhead and clipped the GET STARTED eyebrow above it. It now
wears `hz-display`, the class this app already uses for exactly this (PublicLanding,
v8.5.24) — one way, one rule, every size token and breakpoint. Measured after: 30px
type on 33px leading at desktop, clean two-line wrap at 390px.
`e2e/leading.spec.ts` pins the INVARIANT rather than the call site: no visible text
node on /models, /agents or /playground may compute a `line-height` smaller than its
own `font-size`. That catches the next numeric `lineHeight` anyone writes without
their having to know about RNW's allow-list. It fails on the unfixed tree.
**No overlay was wearing the elevation ladder.** Gui compiles its shadow props to an
atomic rule it injects at runtime as `:root ._bxsh-…` — specificity (0,2,0). The
design-token utilities were plain `.hz-paper` (0,1,0) and lost, so the command
palette, app launcher, floating chat and three menus rendered Gui's
`0 12px 24px rgba(0,0,0,.33)` instead of ring + top highlight + `--hz-elevation-3`.
On the true-black canvas that shadow is nearly invisible — the sheets did not lift off
the page. The utilities are now `:root .hz-x.hz-x` (0,3,0): deterministic in either
stylesheet order, no `!important`.
**And every anchored overlay now wears ONE surface.** Eleven `Popover.Content` call
sites passed Gui's `elevate` while three wore `hz-paper` — one concept, two depths,
plus the same `bordered`/`bg`/`borderColor` triple repeated fourteen times. All
fourteen now spread `~/components/ui/paper`, which holds the surface, the token
elevation and the opacity-only `hz-menu-in` entrance in one place. Verified rendering
on the scope switcher, the network picker, the model selector, the save-prompt
popover and the ⌘K palette: opaque, correctly anchored, ring visible, nothing occluded.
## ONE level-2 nav — the registry declares it, the sidebar renders it (feat/one-second-level-nav)
Clicking into a product revealed its options TWICE. The sidebar drilled into the
product and rendered its sub-nav from the registry (`productSubpages`), and the
module ALSO rendered a private `const TABS` strip in the content column. The two
lists were written independently and disagreed: `/models` showed eight rows in the
rail and four tabs in the content, and they did not even agree on what the index is
called — the rail said "Overview", the module said "Catalog". Eight products
(Containers, Fine-tuning, Tasks, Team, Settings, Zero Trust, Evals, Analytics)
declared NO sub-pages at all, so their real tabs existed only in the content strip
and the rail hid them.
- **The registry is the one source.** `CatalogEntry` gains `indexLabel` — what a
product calls its own index when it is a named surface rather than a summary
(Models → Catalog, Tasks → Workflows, Team → Members, CRM → Companies, Cap Table →
Summary, Evals → Run, Containers → Workloads, Fine-tuning → Jobs, Automations →
Flows, Profile → Account, Settings → General). `productSubpages` reads it, so the
rail, `SubNav`, and ⌘K all say the same word. The eight products missing `subpages`
now declare them; the icons the strips were carrying moved onto the declarations.
- **`components/ui/SubNav.tsx` is the ONE strip**, rendered from `productSubpages` and
hidden at `lg+` (`$lg={{ display: 'none' }}`) because the sidebar owns level 2
there (then `DrillNav`; now `SubRows` — see "The rail stopped drilling" below). One declaration, two mounts — never two navs painting at once. It
takes an optional `href` for a product whose tabs carry URL state (Containers keeps
its `?cluster=` selection across tabs). `subpageIcon` moved here and `dashboard.tsx`
imports it, so the sub-page icon defaults exist once.
- **Level is the URL and nothing else.** New pure `activeSubpage(pathname, id)` (the
level the URL is on, `''` = index) + `subpageHref(id, slug)` (one URL per screen) +
`subpageSlug(entry, seg, showAdmin)` — the validator that replaced every module's
`TABS.some(...)`, so a hand-typed `/tasks/bogus` cannot light a tab the module does
not render, and an admin-only sub-page (Models Routing) cannot be offered to a
customer. Bound to the live registry as `productSubpageSlug` in `match.ts`.
- **18 modules lost their private `TABS`** (Models, Evals, AI Accounts, Containers,
Analytics, Fine-tuning, Team, Automations, Embeddings, Tasks, Functions, Profile,
Router, Settings, Zero Trust, Billing, Cap Table, CRM, Infrastructure) — plus their
bespoke `TabButton`/`TabBar`/`nav`/`path`/`tabPath` helpers. Net 200 lines.
- **Functions had two indexes.** Its `''` route pointed at a `livingOverviewModule`
while `FunctionsModule` carried a second, older `OverviewTab` reachable only via a
bogus URL — and because the index was not the module, `/functions` had no level-2
nav at all on a phone. Now ONE component owns the product at every level and renders
the living-overview board as its index; the dead `functions/OverviewTab.tsx` is gone.
- **`/crm/companies` was a duplicate URL** for the screen `/crm` already renders. The
index IS Companies now (one URL per screen); the old path still resolves.
- **One placement, measured.** The strip renders under the page header, never inside
`PageHeader`'s actions slot: a View is `flex-shrink: 0` with `min-width: auto`, so
in a row it held its max-content width, its own `flex-wrap` never engaged, and at
390px the last tabs painted past the right edge (the same bug `ConsoleFooter` had).
It carries `style={{ flexShrink: 1 }}` + `minW={0}` for the same reason.
- **Not converted, and why:** Playground, Machines, Kubeflow, Cloudflare, Growth,
Providers-Explore and the Errors status filter keep local-state tab strips. Those
are NOT a second level-2 nav — they are in-page view switches the URL never carried,
so converting them changes routing behaviour per product rather than removing a
duplicate. They are the honest follow-up: their level does not survive a reload.
- Verification: `tsc --noEmit` adds zero errors (the one reported, `src/lib/event.ts`
`dsn`, is pre-existing local dep drift — `@hanzo/event` 0.3.1 installed against
`^0.3.4` — and reproduces on a clean origin/main tree); `vitest` **3093 passed**
(+9 level-2 nav: indexLabel, the validator incl. the admin gate, one-URL-per-screen,
and the URL→level read); `next build` compiles (its type step stops on that same
pre-existing drift). RENDER-proven in a browser, `e2e/level-2-nav.spec.ts`, 5/5
against a local server: at 1440 the rail is drilled and the content strip's
COMPUTED `display` is `none` (a hidden element leaves the accessibility tree, so it
is located by test id — `getByRole` cannot see it, which is the whole point); at 390
the strip is the one nav, lists the same labels, every tab has a painted box ≥28px
tall inside the viewport, and the body does not scroll sideways; a reload of
`/models/blend` lands on Blend; Back moves the LEVEL without dropping the rail's
drill or the account-backed pins; and a sweep asserts all 18 converted products
paint no second nav. Screenshots `e2e-shots/level2-{desktop,mobile}-models.png`.
## Find and do — one list view, pins that survive, a palette you can act in
The interaction half of the admin redesign: pin, sort, filter, search, act. Almost
none of it was missing; it was duplicated, unpersisted, or quietly broken. Every
claim below was measured in a browser on computed style and geometry
(`e2e/find-and-do.spec.ts`, 7 tests), not inferred.
**[BUG, measured] Every preference was lost on reload.** Pin a product, reload, and
it was gone — and with it pin groups, product colours, the nav's open sections, and
the workbench state. `Preferences` treated the account as authoritative for keys it
had *never mentioned*: on each account load it replaced both state and the
localStorage cache with `parsePrefs(account.properties['hanzo.preferences'])`. But
the account is projected from the IAM access token's claims, and a preference
written after sign-in is not in a token minted before it — so that value is `{}`,
and the write-through cache was destroyed on every load. Only preferences rewritten
each session (e.g. `guide.used`) appeared to persist. Fix: the account wins for
every key it CARRIES; the cache fills the rest (`preferences-core.mergePrefs`, pure,
7 tests). Stated limit, not papered over: while the account does carry a key it
wins, so clearing it on one device can be re-asserted by a stale token — closing
that needs a read-back of stored preferences (a fresh account read, or properties
riding a refreshed token), which is a session/backend concern.
**ONE list view, persisted per user** (`src/lib/list/`). `useList(id)` holds a
list's search, column order and facets under `list.<id>` in the SAME account-backed
store as pins — so a list narrowed once is found narrowed on the next visit and the
next device. The comparator, header-click reducer and substring predicate are NOT
new: they were promoted verbatim out of the private copy inside `admin/infra/`,
which now re-exports them, so there is one implementation (its 30 tests pass
unchanged). `nextSort` is a strict superset of the shipped reducer — it only also
accepts `null` as "no sort yet" — so no shipped board changes behaviour. `Filters`
(in `ui/Filters`, beside its own atoms) is the one bar: search, facets, and a Reset
that exists only while something is narrowed. A facet is stored only when it
narrows something; "All"/"off" is the ABSENCE of a facet, never a stored sentinel.
Adopted by Models and Marketplace, which between them lose two bespoke search boxes
and four `useState`s. Scope, flagged not decided: preferences are per USER, so a
SuperAdmin masquerading keeps their own sorts — right, I think (they are YOUR
tools), but it is a product call.
**Pins are first-class in search.** `pinnedFirst` (pure, in `pins-core`) is the one
"pinned leads" rule, shared by the sidebar and the palette. Every ⌘K result carries
a pin at its right edge — invisible until the row is reached, lit while pinned, a
26px hit target, `aria-pressed` — and `⌥↵` pins the selection WITHOUT closing, so
curating is repeatable. The default view collects pins under one leading "Pinned"
heading.
**[BUG I introduced, then caught] Pins must not outrank what you typed.** Applying
`pinnedFirst` to the RANKED list meant a barely-matching pinned product beat an
exact name match: typing "billing" and pressing ↵ opened Models. Pins now order the
DEFAULT view only; the moment you type, relevance decides. Locked by a test that
asserts on where you LAND (`agents`→/agents, `billing`→/billing, `vector`→/vector),
not on DOM order.
**[BUG] The resting pin painted at full strength.** `.hz-pin { opacity: 0 }` lost to
Gui's compiled `:root ._ops-…` (0,2,0) — the same specificity trap `.hz-paper` hit.
Fixed by dropping the inline `opacity` prop and doubling the selector
(`:root .hz-pin.hz-pin`). A broken CSS comment then silently killed the whole rule;
only the computed-style assertion caught it. Both are now pinned by a test that
reads `getComputedStyle().opacity` at rest, on hover, and while selected.
Verification: `tsc --noEmit` clean; `vitest` **3121 passed** (+35: list core 22,
preferences-core 7, pinnedFirst 6); `e2e/find-and-do.spec.ts` 7/7 against a local
fixture with screenshots (`palette-pin`, `palette-pinned-first`, `palette-keyboard`,
`list-narrowed`, `list-bar-mobile`), including 4.5:1 contrast and zero horizontal
body scroll at 390px. NOT verified against live admin.hanzo.ai — it is auth-gated
and I will not type a password; one SuperAdmin session re-running this spec closes
that. Untouched and flagged for the caps pass: `MarketplaceModule`'s "CATEGORIES"
and the palette's own uppercased section labels are `textTransform` sites that
belong to that lane, not this one.
## Billing calls the route names the server actually registers
Commerce dropped the compound prefixes from its billing routes — the `/v1/billing/`
namespace already says "billing", so `billing/payment-methods` stuttered. Both servers
register only the short names, measured against the live edge: `/v1/billing/methods`
401, `/v1/billing/settings` 403, `/v1/billing/alerts` 403, while `payment-methods`,
`payment-config` and `spend-alerts` are all 404. The console never followed. Its card
reads, its card writes and its Square-config read were all addressed at routes that no
longer exist, so a new user could not add a card — the revenue path was broken in
production.
The client had already been repointed for alerts, so `payment-methods` (list, save,
detach) and `payment-config` were the ones still dead. They now build `methods` and
`settings`. There is deliberately no alias and no fallback: one name per concept.
The tests were part of the defect, not the safety net. Every suite around payment
methods stubbed a response body and asserted the normalization, so a client pointed at
a 404 stayed green — the exact reason this survived. The URL is now pinned where the
request is actually made, including the two reads nothing had ever asserted
(`methods`, `settings`) and `alerts` beside them. Reverting any of the four short names
turns the suite red, which was checked rather than assumed.
`POST /v1/billing/me/welcome` and everything feeding it is deleted, not repointed.
Commerce removed that route on purpose: it was a self-service mint, a browser could
grant its own org $5, and commerce's own `api/billing/mint_gates_test.go` names it the
TOCTOU double-mint. Credit is minted only through the mint-gated `POST
/v1/billing/credit`. The call had been failing silently, so restoring it would have
re-opened a closed money hole to fix nothing. The trial credit still lands — commerce
grants it server-side when a card is vaulted, and signup grants it server-side — and
that path is untouched. `src/lib/billing/welcome.ts` had no callers left at all.
Scope, checked rather than assumed: `/v1/finance/payment-methods` is still 401 (alive)
and `/v1/finance/methods` is 404, so the finance ledger keeps the compound name — a
blanket repo-wide rename would have broken it. The Billing Center's tab slugs
(`/billing/payment-methods`, `/billing/credits`) are console page URLs, not server
routes, and are unchanged.
Two headlines were lying about which layer failed. "Card top-up isn't available on this
deployment yet" and "Adding a card isn't available on this deployment yet" both fire
when the ORG has no Square `applicationId`/`locationId` — a per-organization
configuration, not a property of the deployment. Both now name the organization, as
does the onboarding step's "Payments aren't set up", which had the same defect. The
stale `GET /v1/billing/payment-config` endpoint hints under those cards now read
`settings`.
## The assistant has one home, and the app directory is one you can walk
Three fixes to the console's own chrome. Each root cause was measured in a browser
on computed style, geometry or where the browser lands — never inferred from source.
**The assistant lived in a third place.** `FloatingChat` owned every shape the
assistant can take (the sheet, the docked column, the dock state) except the way
in, which was two small buttons in the TOPBAR — a brand-H "Chat with Hanzo" and a
"Talk to Hanzo" mic — wedged between the search box and the org/theme/alert
cluster. On a 390px phone that put five controls in the header and squeezed the
search field to "Search or jump…". Both controls moved into `AssistantFab`, one
floating cluster fixed bottom-right, in the corner the assistant actually appears
in. Chat and voice are the same surface opened two ways, so they sit together.
Nothing about the assistant was rewritten: the FAB calls the same `openChat` /
`startVoice` the topbar called, and `open`/`toggle`/`ask` still drive it
programmatically (the Code hub's "Ask AI").
It is suppressed exactly where the assistant is already on screen — while the
sheet is open, on the pages that ARE a composer, and, at `lg+` only, while it is
docked as a column. That last half is a CSS media prop rather than a JS branch so
SSR and first paint agree, and it sits above the Developers dock (whose collapsed
bar is 44px and exists only at `lg+`).
**[BUG, measured] All products was a directory you could not walk.** The pane that
lists every Hanzo app — the sidebar's "All products", the one place the whole
catalog is browsable — rendered each app as an inert `XStack`: a plain `DIV` with
`role=null` and `cursor: auto`, no handler, no pointer affordance. Measured, not
read. The only live control in the row was the pin, so a user could curate the
sidebar but could not open anything from the list. The row now opens its app
through the shared `openProduct` — the ONE opener the sidebar, ⌘K and the category
pages already route through — and closes the pane behind it, because a directory is
not a destination. Pin stays a separate control on the same row and stops the press
from bubbling: curating never navigates, navigating never curates.
**[BUG] A pin made after sign-in was thrown away on the next reload.** Preferences
are read off `properties['hanzo.preferences']` in the IAM access token's claims — a
SNAPSHOT taken when that token was minted. An earlier lane fixed the case where the
snapshot is SILENT about a key. The other half was never closed: once a user has
saved anything, the next token CARRIES a snapshot, and the merge let it win over a
newer local write. So the second pin onward read as pinned and was gone after F5.
The merge is now told the ordering it was missing. `Account` carries the token's
own `iat`; the provider stamps `…prefs.<user>.writtenAt` when — and only when — the
SERVER acknowledges a write; `mergePrefs(cached, fromAccount, order)` lets the cache
win only when a confirmed write is newer than the snapshot. Last writer wins, and
both writers are now identifiable. A fresh device (no cache, no stamp) and a fresh
sign-in (token minted after the write) both still take the account wholesale, so
cross-device is preserved. Stamping only server-confirmed writes is what keeps this
from being localStorage impersonating a backend: a save that never landed earns
nothing and the account stays authoritative.
**Backend gap, named rather than papered over.** There is no READ for this
document. `PATCH /v1/ai/preferences` (hanzoai/ai `UpdatePreferences`) writes it to
the IAM user's `properties['hanzo.preferences']` and returns the merged result;
nothing serves a GET, so the token's snapshot is the only read the console has. The
smallest seam that removes the ordering problem entirely is `GET
/v1/ai/preferences` returning that property after the handler's existing
`refreshSessionUser` — the write path already does every part of it. Better still
is `GET/PATCH /v1/prefs` (hanzoai/cloud `apps/prefs`), the canonical cross-surface
plane, which answers 503 on api.hanzo.ai today.
**House rule: one filled CTA.** Counted by computed background luminance on the
rendered home, not by reading JSX: FIVE white-filled buttons competed — "Take the
tour", the getting-started card's active step, and all three `PrimaryActionTile`
CTAs (two of them saying "Get API key"). The same measurement now returns ONE: the
checklist's ACTIVE step, the thing to do next. The tour is a neutral aside beside its
dismiss, and the three tiles are neutral because they are PEERS — a menu of things
you can do, not a call to action, and three primaries are none.
**Verification.** `tsc --noEmit` clean; `vitest` 3175 passed / 8 skipped (256 files,
+6 ordering tests). RED→GREEN proven both ways: disabling `cacheIsNewer` turns the
two new ordering tests red and the browser test with it. `e2e/assistant-fab-and-apps.spec.ts`
(4 tests, 1440 and 390) asserts the control's BOX is in the bottom-right quadrant and
≥44px, that `.hz-topbar` carries no assistant control, that clicking an app in All
products LANDS on `/agents`, and that a pin survives a reload under a token whose
snapshot is an hour old. `e2e/chrome-brand-voice.spec.ts` was retargeted, not
deleted — every claim it made still holds, only the location moved.
Two spec gotchas worth keeping. `_session.ts`'s `b64` emitted plain base64; that is
fine while a forged payload is tiny, but `+`/`/` appear as soon as one grows (a
`properties` bag is enough) and a strict decoder rejects the token outright — the SDK
reports signed out and the app sits on its loader forever. It emits base64URL now,
which is what a JWT segment actually is. And the assistant's composer carries its own
mic with the same `Talk to Hanzo` label, mounted-but-hidden until the panel opens, so
a bare attribute locator matches that one first: scope to `getByTestId('assistant-fab')`.
## The agent quickstart, and the rail that stopped drilling (v8.5.62)
Two changes that share a shape: something that looked finished was standing in for
the thing itself.
**The builder had no way in.** `AgentBuilder` — the canonical, host-agnostic one —
was reachable only as a form in a side pane, from a board you first had to have
agents to be looking at. `agents/quickstart` is the way someone with none starts:
describe what you want in a sentence, or take a template, then configure, run and
integrate.
- **Every step is an endpoint**, which is the whole design constraint. Describe →
`POST /v1/chat/completions` (`draftAgent`) turns a sentence into a spec; Configure →
the SAME `AgentBuilder`, seeded; Run → `POST /v1/agents/:ref/run` executes it and
shows the RECORDED run; Integrate → prints the request that just worked. A ladder
of steps is a promise about what happens, and a step that only draws a checkmark
turns the promise into decoration. Steps 1 and 3 are optional by construction —
their loaders may be absent, and the step then says exactly what is missing.
- **`components/agent-builder/templates.ts`** — eight presets, pure data. A template is
a PRESET, never a promise: it may only carry fields `toCreateBody` already expresses
(`name`, `description`, `systemPrompt`, and the real `AgentConfig` knobs), and a test
pins exactly that. **None names a tool.** Tools are per-org, so a hardcoded
`web.search` would name something that may not exist and would fail at the agent's
FIRST invocation rather than in the form. What a template CAN say truthfully is
`useTools` / `webSearch`, which are real switches in the agent contract.
- **The tool plane was live the whole time.** `loaders.ts` said "No live tool catalog
endpoint on this deployment yet" and left the field typeable-only. `GET /v1/tools` is
bound and serving — one flat set spanning connector actions, functions, zap-service
routes, agents, skills and the org's own MCP servers, deduplicated by name, each
flagged `activated`. `lib/api/tools.ts` reads it, `proxy-allow` admits the head, and
`/v1/tools/call` is REFUSED there: running a tool belongs to whatever runs an agent,
never to a browser tab. An org with nothing activated gets `{"tools":[]}` — a real
empty answer, shown honestly rather than papered over.
- **`defaultModel` was picking an embeddings model.** It named `zen-omni` as its exact
match, and the live catalog does not carry that id — so the exact arm never fired and
the fallback ran instead: `^zen[-.]` over an alphabetically sorted catalog, which
selects `zen-embedding`. Every agent created without touching the model field was
pointed at a SKU that cannot hold a conversation, and nothing caught it because the
dead exact-match read like the rule. The family test is `^zen\d` now, because zen's
naming splits cleanly: **`zen5*` are the text models; `zen-<noun>` names a MODALITY**
(embedding, image, video, rerank, voice, vl, guard). The model and tool placeholders
were advertising the same dead id and two invented tool names; both now say things
that exist.
**The rail stopped drilling.** Clicking a product used to swap the ENTIRE sidebar for
that product's sub-nav, behind a "Back to all products" button. The options were
identical either way — what the drill took away was every OTHER product, which is
precisely what someone needs when the reason they opened the rail was to go somewhere
else. `SubRows` replaces `DrillNav`: a product's sub-pages expand beneath its own row,
indented on a hairline, `inert` when collapsed.
- **The label navigates; the chevron only opens and closes.** One target doing both
would make "show me what is in here" and "take me there" the same gesture.
- `productIsOpen` / `toggleProduct` in `nav-accordion.ts`, beside the category pair and
keyed apart from it. The default is the OPPOSITE of a category's, deliberately:
categories are few and describe the catalog, so they open; products are many and each
brings four to eight rows, so opening them all would bury the catalog under its own
detail. The product you are IN is open unless you closed it, and that choice persists.
- **A pinned product appears twice** — once under Pinned, once in its category — and
exactly ONE copy may carry the sub-list. Two copies is two navs painting at once,
which is the thing this rail exists to avoid, and it doubles the rail's height for no
information. The pinned copy owns it.
**Verification.** `tsc --noEmit` clean; `vitest` **3259 passed / 8 skipped** (262 files;
+draft/handle parsing, +templates, +the product accordion, +the tool-plane allow/refuse
pair, and a `defaultModel` test that goes red on the `zen-embedding` regression).
RENDER-proven: `e2e/agent-quickstart.spec.ts` (3 tests) asserts the ladder, that the
gallery sits to the RIGHT of the composer by measured geometry at 1440, that searching
narrows it, that picking Deep researcher carries its handle and prompt into step 2, and
that 390 stacks without the body scrolling sideways. `e2e/level-2-nav.spec.ts` (5/5) was
retargeted, not deleted: it now asserts "All products" is still on screen while a
product is open — the invariant the drill could never have satisfied — and that no
"Back to all products" button exists on any of the 18 converted products.
**ONE door.** The board's New-Agent button opened the builder in a side pane —
the same component, reached by a different shape, with no templates, no drafting,
and nowhere to run what it made. It goes to the quickstart now and
`NewAgentForm` is deleted; two entrances to one builder is two things to keep in
step, and the pane was the lesser of them. A spec clicks the board's CTA and
asserts the URL lands on `/agents/quickstart`.
One placement note that cost a debug cycle: the quickstart branch must return BEFORE
`AgentsModule`'s loading/error/empty states. Building an agent does not depend on
reading the ones that exist, and the moments you most need the quickstart — no agents
yet, or the registry not answering — are exactly the ones those early returns swallow
it in.
+36 -1
View File
@@ -1,5 +1,5 @@
Hanzo Cloud Console (console2)
Copyright (c) Hanzo AI, Inc. Licensed BSD-3-Clause (see LICENSE).
Copyright (c) Hanzo AI, Inc. Licensed MIT OR Apache-2.0 (see LICENSE) per HIP-0137.
------------------------------------------------------------------------
Third-party attribution
@@ -39,3 +39,38 @@ Langfuse EE / commercial ("ee") code is neither used nor referenced.
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
------------------------------------------------------------------------
Vendored MIT-licensed code
------------------------------------------------------------------------
Portions of this software are derived from upstream MIT-licensed code. Those
copyright notices are retained here per the MIT License's terms; they were
previously carried in LICENSE, which is reserved for this project's own
BSD-3-Clause grant.
Copyright (c) 2020 Nate Wienert (Tamagui)
Copyright (c) 2015-present, Nicolas Gallagher. (react-native-web)
Copyright (c) 2015-present, Facebook, Inc. (react-native-web)
Copyright (c) 2021 Radix (Radix UI)
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V. (Framer Motion)
Copyright (c) 2022 WorkOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3 -1
View File
@@ -48,4 +48,6 @@ the product-module registry, and the Providers surface). Endpoint reference in
## License
BSD-3-Clause. Copyright (c) 2026-present, Hanzo AI, Inc.
`MIT OR Apache-2.0` at your option — see [LICENSE](./LICENSE),
[LICENSE-MIT](./LICENSE-MIT), [LICENSE-APACHE](./LICENSE-APACHE).
Copyright (c) 2026-present, Hanzo AI, Inc. Estate-wide licensing standard: HIP-0137 (`hanzoai/hips`).
+6 -1
View File
@@ -3,11 +3,14 @@ import type { ReactNode } from 'react'
import { Preferences } from '~/lib/products/preferences'
import { Toast } from '~/components/ui/Toast'
import { Entry } from '~/entry/entry'
import { Host } from '~/entry/host'
/**
* The console entry, decomplected (see src/entry/). `Preferences` + `Toast` are the
* session-tier context: the stage RESOLVER reads the onboarding preference, and the
* onboard wizard + every module report through Toast — so they sit above the switch.
* `Host` answers the two effects `@hanzo/ui/product`'s state cards ask for (sign in,
* add credits), so every card below renders its affordance without being handed one.
* `Entry` computes ONE stage value from the session and renders EXACTLY one surface
* (sign-in · waitlist · org · onboard · dashboard).
*/
@@ -15,7 +18,9 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<Preferences>
<Toast>
<Entry>{children}</Entry>
<Host>
<Entry>{children}</Entry>
</Host>
</Toast>
</Preferences>
)
+10 -7
View File
@@ -21,15 +21,12 @@ import { ProductRoute } from '~/components/ProductRoute'
import { openProduct } from '~/lib/products/open'
import { useFavorites } from '~/lib/products/favorites'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { ProductIcon } from '~/components/ui/ProductIcon'
import type { IconLike } from '~/components/ui/color'
import { useProductColors } from '~/lib/products/pins'
import { FadeIn } from '~/components/ui/FadeIn'
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
import { ResourceOverview } from '~/components/products/overview/ResourceOverview'
import { ProductObservability } from '~/components/products/observability/ProductObservability'
import { FadeIn, PageHeader, type IconLike } from '@hanzo/ui/product'
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
// sparklines, streaming activity) — the SAME component every product overview uses.
@@ -148,13 +145,19 @@ function PrimaryActionTile({
{description}
</Text>
<XStack>
<PrimaryButton
{/* Neutral, not filled. These three tiles are PEERS — a menu of things you can
do, not a call to action — so three white buttons side by side gave the
screen three primaries and therefore none. The one filled action on this
page is the getting-started card's active step: the thing to do NEXT. */}
<Button
size="$3"
borderWidth={1}
borderColor="$borderColor"
iconAfter={external ? <ExternalLink size={15} /> : <ArrowRight size={15} />}
onPress={onPress}
>
{ctaLabel}
</PrimaryButton>
</Button>
</XStack>
</Card>
)
@@ -234,7 +237,7 @@ export default function DashboardHome() {
icon={HandCoins}
color={colorOf('authors')}
title="Earn from your OSS"
description="Earn 20% of the compute margin your open-source project drives when teams run it on Hanzo Cloud — paid to your Hanzo wallet."
description="Earn 20% of the compute margin your open-source project drives when organizations run it on Hanzo Cloud — paid to your Hanzo wallet."
ctaLabel="Start earning"
onPress={() => push('/authors')}
/>
+4 -4
View File
@@ -95,7 +95,7 @@ export async function GET(req: NextRequest, ctx: Ctx) {
/**
* POST — the GLOBAL-admin mutations that ride the same god-view gate
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/spend-caps` create). Identical
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/caps` create). Identical
* path through `getAdminGate` (fail-closed 403) → `forwardWithUserBearer`, which applies
* the same-origin CSRF check to this mutating method BEFORE resolving the user, streams
* the JSON body through, and re-validates the path against `allowAdminSurface` (so a POST
@@ -116,16 +116,16 @@ export async function PUT(req: NextRequest, ctx: Ctx) {
}
/**
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/spend-caps/:id?org=<slug>`,
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/caps/:id?org=<slug>`,
* override an org's usage cap). Same gate + same CSRF/traversal hardening; the `:id`
* sub-path passes because `allowAdminSurface` admits `v1/admin/spend-caps[/...]`.
* sub-path passes because `allowAdminSurface` admits `v1/admin/caps[/...]`.
*/
export async function PATCH(req: NextRequest, ctx: Ctx) {
return handle(req, ctx)
}
/**
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/spend-caps/:id?org=<slug>`,
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/caps/:id?org=<slug>`,
* remove an org's usage cap). Same gate + CSRF/traversal hardening as the other
* mutating verbs; only an allow-listed head/sub-path is ever reached.
*/
+7 -2
View File
@@ -59,7 +59,12 @@ async function handle(req: NextRequest, segments: string[]): Promise<NextRespons
if (segments.length !== 1 || segments[0] !== 'secrets') return notFound()
const org = orgFor(gate, req)
const base = `${kmsBaseUrl()}/v1/kms/orgs/${encodeURIComponent(org)}/secrets`
// The org travels on the IDENTITY channel, never the URL: cloud's KMS surface
// is /v1/kms/secrets and reads the acted-on org from the validated principal
// (X-Org-Id — for a SuperAdmin, the switched-into org; the same one-predicate
// switch every other subsystem honors). URL-addressed orgs were removed
// server-side because a path that names a tenant is caller-selectable.
const base = `${kmsBaseUrl()}/v1/kms/secrets`
const q = req.nextUrl.searchParams
const name = q.get('name') ?? ''
const path = q.get('path') ?? ''
@@ -105,7 +110,7 @@ async function handle(req: NextRequest, segments: string[]): Promise<NextRespons
)
}
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json' }
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json', 'X-Org-Id': org }
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
+1 -1
View File
@@ -3,7 +3,7 @@
*
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
* browser session cookie alone is rejected. Rather than ship the user's durable
* `hk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
* `sk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
* `/v1/<aihead>` (the /v1-first law); `next.config.mjs` dispatches those heads to THIS `/ai`
* proxy (re-rooting the upstream at `v1/` — invisible to the client). `forwardWithUserBearer`
* resolves the user, mints a SHORT-LIVED, user-bound IAM token (shared per-user cache in
+2 -2
View File
@@ -2,7 +2,7 @@
* /console/mfa/<action> — console-native two-factor (TOTP) enrollment BFF.
*
* WHY console-native: the console delegated 2FA to hanzo.id's account page, but the
* custom hanzo.id login worker doesn't establish a Casdoor account session, so a
* custom hanzo.id login worker doesn't establish an IAM account session, so a
* user who signed in through it lands on an account page that can't manage MFA
* (setup returns "Unauthorized operation"). This closes that gap: the user enrolls
* 2FA IN the console. We forward each IAM MFA op as the caller's OWN user bearer
@@ -20,7 +20,7 @@ import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
export const runtime = 'nodejs'
const TOTP = 'app' // Casdoor TotpType
const TOTP = 'app' // IAM TotpType
/**
* IAM endpoint + the params each action sends. owner/name are ALWAYS included and
-125
View File
@@ -1,125 +0,0 @@
/* Hanzo is monochrome. One hue rendered through an opacity ladder.
Base ladder = Tailwind neutral (tailwind.config.ts). Semantic names match
hanzo.ai's CSS variables exactly, so code copies over 1:1.
DARK IS THE DEFAULT THEME (hanzo.ai mounts ThemeProvider defaultTheme="dark"). */
:root{
/* ——— base neutral ladder ——— */
--neutral-50:#FAFAFA;
--neutral-100:#F5F5F5;
--neutral-200:#E5E5E5;
--neutral-300:#D4D4D4;
--neutral-400:#A3A3A3;
--neutral-500:#737373;
--neutral-600:#525252;
--neutral-700:#404040;
--neutral-800:#262626;
--neutral-900:#171717;
--neutral-950:#0A0A0A;
--pure-black:#000000;
--pure-white:#FFFFFF;
/* Press-kit brand constants (public/press/hanzo/README.md). */
--hanzo-black:#0A0A0B;
--hanzo-white:#FFFFFF;
/* ——— the opacity ladder: the real palette ——— */
--white-05:rgb(255 255 255 / .05);
--white-10:rgb(255 255 255 / .10);
--white-15:rgb(255 255 255 / .15);
--white-20:rgb(255 255 255 / .20);
--white-30:rgb(255 255 255 / .30);
--white-40:rgb(255 255 255 / .40);
--white-60:rgb(255 255 255 / .60);
--white-80:rgb(255 255 255 / .80);
/* ——— semantic aliases (dark, the default) ——— */
--background:#000000;
--foreground:#ededed;
--card:#0a0a0a;
--card-foreground:#f5f5f5;
--popover:#0a0a0a;
--popover-foreground:#f5f5f5;
--primary:#ffffff;
--primary-foreground:#000000;
--secondary:#1a1a1a;
--secondary-foreground:#f5f5f5;
--muted:#101010;
--muted-foreground:#888888;
--accent:#1a1a1a;
--accent-foreground:#f5f5f5;
--destructive:#666666;
--destructive-foreground:#f5f5f5;
--border:#1f1f1f;
--input:#1f1f1f;
--ring:#333333;
--brand:#e4e4e7;
--brand-foreground:#09090b;
--brand-muted:#a3a3a3;
--black:#000000;
--white:#f5f5f5;
/* ——— surface recipes (card fills used across hanzo.ai) ——— */
--surface-page:var(--background);
--surface-card:rgb(23 23 23 / .5); /* bg-neutral-900/50 — grid tiles */
--surface-card-emphasis:rgb(23 23 23 / .8);/* bg-neutral-900/80 — featured */
--surface-card-quiet:rgb(23 23 23 / .4); /* bg-neutral-900/40 — story cards */
--surface-overlay:rgb(10 10 10 / .95); /* dropdown / popover panels */
--surface-header:rgb(0 0 0 / .7); /* fixed nav, with backdrop blur */
--border-hairline:var(--neutral-800);
--border-card:var(--white-10);
--border-strong:var(--neutral-700);
/* ——— text ranks ——— */
--text-primary:var(--pure-white);
--text-secondary:var(--white-80);
--text-tertiary:var(--white-60);
--text-helper:var(--muted-foreground);
--text-disabled:var(--white-30);
/* ——— the ONLY permitted hues (DESIGN.md §2.4) ——— */
--state-error:#ef4444; /* red-500 — destructive / blocking error */
--state-error-text:#fca5a5; /* red-300 */
--state-error-bg:rgb(239 68 68 / .1);
--state-online:#4ade80; /* green-400 — live status dot */
--state-success:#22c55e; /* green-500 — "Free" / "Save N%" callouts */
--chrome-dot-red:rgb(239 68 68 / .6);
--chrome-dot-yellow:rgb(234 179 8 / .6);
--chrome-dot-green:rgb(34 197 94 / .6);
}
/* Light theme — the same tokens, inverted. Rare: only /brand-style docs pages. */
.light{
--background:#ffffff;
--foreground:#0a0a0a;
--card:#f5f5f5;
--card-foreground:#0a0a0a;
--popover:#ffffff;
--popover-foreground:#0a0a0a;
--primary:#0a0a0a;
--primary-foreground:#ffffff;
--secondary:#f5f5f5;
--secondary-foreground:#0a0a0a;
--muted:#f5f5f5;
--muted-foreground:#525252;
--accent:#f5f5f5;
--accent-foreground:#0a0a0a;
--destructive:#999999;
--destructive-foreground:#ffffff;
--border:#e5e5e5;
--input:#e5e5e5;
--ring:#d4d4d4;
--black:#0a0a0a;
--white:#ffffff;
--surface-card:#f5f5f5;
--surface-card-emphasis:#ffffff;
--surface-card-quiet:#fafafa;
--surface-overlay:rgb(255 255 255 / .95);
--surface-header:rgb(255 255 255 / .8);
--border-hairline:var(--neutral-200);
--border-card:rgb(0 0 0 / .1);
--border-strong:var(--neutral-300);
--text-primary:var(--neutral-950);
--text-secondary:rgb(10 10 10 / .8);
--text-tertiary:rgb(10 10 10 / .6);
--text-disabled:rgb(10 10 10 / .3);
}
-19
View File
@@ -1,19 +0,0 @@
/* Hanzo barely uses shadow: on black, elevation reads as a hairline border plus
a wide, very dark drop. Only two levels ship (Tailwind's shadow-2xl for
floating surfaces) plus the ambient radial glow used behind heroes. */
:root{
--shadow-none:none;
--shadow-floating:0 25px 50px -12px rgb(0 0 0 / .25); /* shadow-2xl: composer, dropdowns, mega panel */
--shadow-inset-hairline:inset 0 0 0 1px var(--white-10);
--ring-focus:0 0 0 2px var(--ring);
/* Ambient hero glow — a single white radial, blurred 120px, low opacity. */
--glow-hero:radial-gradient(circle,rgb(255 255 255 / .12) 0%,transparent 68%); /* @kind color */
--glow-hero-blur:120px;
/* Card top-corner sheen used on the story cards. */
--sheen-card:radial-gradient(120% 120% at 80% 0%,rgb(255 255 255 / .08) 0%,transparent 55%); /* @kind color */
/* Chrome text: the canonical headline gradient. Never a saturated rainbow. */
--gradient-chrome:linear-gradient(to right,#ffffff,var(--white-80),var(--white-60));
--gradient-chrome-2:linear-gradient(to right,#ffffff,var(--neutral-500));
/* Section-top protection gradient (hero overlays). */
--gradient-protect:linear-gradient(to bottom,var(--white-10),transparent);
}
+19 -21
View File
@@ -1,30 +1,28 @@
/* ─────────────────────────────────────────────────────────────────────────────
Hanzo Design System tokens — VENDORED from hanzoai/design (@hanzo/design).
Hanzo Design System tokens — the PUBLISHED @hanzo/design package.
This directory is the canonical Hanzo design language expressed as CSS custom
properties: the monochrome neutral ladder + opacity ladder, the ONLY permitted
semantic hues (live/success/warning/error), type scale, 4px spacing ramp,
radius, elevation and motion. It is the SINGLE SOURCE OF TRUTH for how every
Hanzo surface looks.
These were vendored under app/design/ (synced 2026-07-24) only because the
package was not yet on npm. It is now (@hanzo/design ≥ 0.4.6), so the console
reads the real dependency and can no longer drift a border rework behind the
rest of the fleet. The token subpaths are named one by one rather than pulling
`@hanzo/design/styles.css`: that entry chains relative `@import url(...)`s that
Next's CSS pipeline resolves as modules, not sibling files, so the explicit
published subpaths are the resolvable form of the same import.
@hanzo/design is not yet published to npm, so its token layer is vendored here
verbatim (the README's stated contract: "code copies over 1:1"). Do NOT edit
these files in the console — edit them in hanzoai/design and re-sync. Synced
from hanzoai/design tokens/ on 2026-07-24.
The console's Tamagui theme (app/globals.css) DERIVES its --colorN ladder and
borders from this neutral ladder, so the whole product reads from one palette.
Fonts are deliberately NOT imported from the package: the console loads the
Geist faces via app/fonts.css, and the `:root` shim below lets the vendored
typography roles resolve without a second copy.
───────────────────────────────────────────────────────────────────────────── */
@import './colors.css';
@import './typography.css';
@import './spacing.css';
@import './radius.css';
@import './elevation.css';
@import './motion.css';
@import './z.css';
@import '@hanzo/design/tokens/colors.css';
@import '@hanzo/design/tokens/typography.css';
@import '@hanzo/design/tokens/spacing.css';
@import '@hanzo/design/tokens/radius.css';
@import '@hanzo/design/tokens/elevation.css';
@import '@hanzo/design/tokens/motion.css';
@import '@hanzo/design/tokens/z.css';
/* Font families — the console loads the Geist faces via app/fonts.css; these vars
let the vendored typography roles (--type-*) resolve without re-importing fonts. */
let the typography roles (--type-*) resolve without re-importing fonts. */
:root {
--font-sans: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-sans);
-24
View File
@@ -1,24 +0,0 @@
/* Motion is restrained: fade + small rise, CSS-only hovers, one breathing glow.
No springs, no bounce, no parallax, no autoplay carousels. */
:root{
--duration-fast:150ms; /* @kind other */ /* dropdown / panel open */
--duration-base:300ms; /* @kind other */ /* slide-up-fade */
--duration-slow:400ms; /* @kind other */ /* hero element entry */
--duration-slower:500ms; /* @kind other */ /* section entry */
--duration-glow:9s; /* @kind other */ /* ambient radial breathe */
--ease-out:cubic-bezier(0,0,0.2,1); /* @kind other */
--ease-in-out:cubic-bezier(0.4,0,0.2,1); /* @kind other */
--stagger:60ms; /* @kind other */ /* per-element delay in a group */
--entry-rise:16px; /* hero y-offset */
--entry-rise-lg:24px; /* card y-offset */
}
@keyframes hanzo-fade-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-fade-down{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-slide-up-fade{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
@keyframes hanzo-glow{0%,100%{transform:scale(1);opacity:.45}50%{transform:scale(1.08);opacity:.65}}
@keyframes hanzo-pulse-dot{0%,100%{opacity:1}50%{opacity:.35}}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
}
-10
View File
@@ -1,10 +0,0 @@
:root{
--radius:0.5rem; /* the base token (globals.css) */
--radius-sm:0.375rem; /* rounded-md — buttons, inputs */
--radius-md:0.5rem;
--radius-lg:0.75rem; /* rounded-xl — cards */
--radius-xl:1rem; /* rounded-2xl — dropdown panels */
--radius-2xl:1.5rem; /* rounded-3xl — story / hero cards */
--radius-composer:28px; /* the chat composer, exactly 28px */
--radius-full:9999px; /* pills, CTAs, avatars, badges */
}
-45
View File
@@ -1,45 +0,0 @@
/* Spacing: the 4px Tailwind ramp is what ships. The golden-ratio ramp below is
declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
reference) — use it for editorial layouts, not for component padding. */
:root{
--space-0:0;
--space-1:0.25rem;
--space-2:0.5rem;
--space-3:0.75rem;
--space-4:1rem;
--space-5:1.25rem;
--space-6:1.5rem;
--space-8:2rem;
--space-10:2.5rem;
--space-12:3rem;
--space-14:3.5rem;
--space-16:4rem;
--space-20:5rem;
--space-24:6rem;
--space-32:8rem;
/* golden ramp (φ) — hanzo.ai tailwind.config.ts */
--golden-1:0.25rem;
--golden-2:0.405rem;
--golden-3:0.654rem;
--golden-4:1.059rem;
--golden-5:1.713rem;
--golden-6:2.772rem;
--golden-7:4.487rem;
--golden-8:7.26rem;
--golden-9:11.749rem;
--golden-split:38.2% 61.8%; /* @kind other */
/* layout rules (DESIGN.md §1.3) */
--container-max:80rem; /* max-w-7xl — grids */
--container-prose:48rem; /* max-w-3xl — centered text */
--container-wide:72rem; /* max-w-6xl — landing sections */
--gutter:1rem; /* px-4 */
--gutter-sm:1.5rem; /* sm:px-6 */
--gutter-lg:2rem; /* lg:px-8 */
--section-y:4rem; /* py-16 — content sections */
--section-y-lg:6rem; /* py-24 — landing sections */
--hero-y:5rem; /* py-20 … */
--hero-y-lg:8rem; /* … lg:py-32 */
--header-height:4rem;
}
-46
View File
@@ -1,46 +0,0 @@
/* TIGHT app-first type scale — the compact developer-app register (linear.app /
vercel.com / the Codex desktop look), the Hanzo default across chat / app /
desktop. Base is 14px, nav 13px, labels 11px; display sizes tightened. Kept in
lockstep with @hanzo/brand (styles/variables.css --font-size-* + typography.ts)
— the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
on :root to retune density on demand. */
:root{
--text-xs:0.6875rem; --leading-xs:1rem; /* 11px — eyebrows / section labels */
--text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px — nav labels, dense body */
--text-base:0.875rem; --leading-base:1.35rem; /* 14px — base app text (was 16px) */
--text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
--text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
--text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
--text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
--text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
--text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
--text-6xl:3.25rem; --leading-6xl:1; /* 52px */
--text-7xl:4rem; --leading-7xl:1; /* 64px */
--weight-normal:400;
--weight-medium:500;
--weight-semibold:600;
--weight-bold:700;
--tracking-tight:-0.025em;
--tracking-normal:0em;
--tracking-wide:0.025em;
--tracking-widest:0.1em; /* eyebrows / uppercase category labels */
--leading-none:1;
--leading-tight:1.25;
--leading-snug:1.375;
--leading-normal:1.5;
--leading-relaxed:1.625;
--leading-golden:1.618;
/* named roles */
--type-hero:600 var(--text-5xl)/1.05 var(--font-display);
--type-h2:700 var(--text-4xl)/var(--leading-4xl) var(--font-display);
--type-h3:600 var(--text-xl)/var(--leading-xl) var(--font-display);
--type-lead:400 var(--text-lg)/var(--leading-relaxed) var(--font-sans);
--type-body:400 var(--text-sm)/var(--leading-sm) var(--font-sans);
--type-caption:400 var(--text-xs)/var(--leading-xs) var(--font-sans);
--type-code:400 var(--text-sm)/var(--leading-relaxed) var(--font-mono);
--type-eyebrow:600 0.625rem/1 var(--font-sans);
}
-14
View File
@@ -1,14 +0,0 @@
/* Stacking order — the one z-index ladder. Layers are named by role, never by a
magic number, so a dropdown opened from the fixed header always sits above it
and nothing ever reaches for 9999. Below --z-raised is ordinary document flow. */
:root{
--z-base:0;
--z-raised:10; /* hover-lifted cards, sticky table headers */
--z-sticky:200; /* pinned section rails */
--z-header:300; /* the fixed site header */
--z-dropdown:400; /* menus, selects, comboboxes */
--z-overlay:500; /* dialog / sheet scrim */
--z-modal:600; /* dialogs, sheets, command palette */
--z-popover:700; /* popovers, tooltips (also when anchored in modals)*/
--z-toast:800; /* toasts / notifications — always on top */
}
+57 -10
View File
@@ -10,6 +10,14 @@ body {
background-color: var(--background, #000000);
color: var(--color, #ededf1);
font-family: 'Geist', system-ui, -apple-system, sans-serif;
/* The base of the ONE type scale. Without this the body inherits the browser's
16px root, and every element that does not name a size token — a Gui <Button>
label, a bare <span>, anything the ladder does not reach — renders at a size
that belongs to no scale. That was the single largest source of type drift in
the console: hundreds of nodes painting the retired 16px base beside a 14px
one. `--text-base` is the same 14px the Gui `$3` token resolves to
(gui.config.ts), so the inherited size and the named size agree. */
font-size: var(--text-base, 0.875rem);
/* Calm type rendering — crisp, low-glare, comfortable rhythm for a full workday. */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -390,6 +398,36 @@ html:root.t_light {
}
}
/* Row-edge pin — the quiet affordance on a search result. It is a REAL, focusable
control at all times (opacity, never `display:none`), so the keyboard and a
screen reader always reach it; it is simply not drawn until the pointer reaches
its row. A pinned or keyboard-selected row opts out of the class entirely, so
its pin stays lit — the lit ones are STATE, not chrome.
Touch has no hover, so `hover:none` pointers keep it visible: on a phone an
invisible control is an absent one.
Doubled selector (`:root .hz-pin.hz-pin`, specificity 0,3,0) for the same reason
`.hz-paper` is doubled above: Gui injects its compiled style props at `:root ._x-…`
(0,2,0), so a plain `.hz-pin` loses and the pin paints at full strength forever. */
:root .hz-pin.hz-pin {
opacity: 0;
transition: opacity 140ms ease-out;
}
:root .hz-row-pin:hover .hz-pin.hz-pin,
:root .hz-pin.hz-pin:focus-within,
:root .hz-pin.hz-pin:hover {
opacity: 1;
}
@media (hover: none) {
:root .hz-pin.hz-pin {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
:root .hz-pin.hz-pin {
transition: none;
}
}
/* ── Touch targets — WCAG 2.5.5 (AAA) / Apple HIG ≥44px ──────────────────────
On phones/tablets (<lg) every control inside the mobile nav drawer must be at
least 44px tall to tap reliably. Scoped to `.hz-touch-target` (set on the drawer
@@ -415,7 +453,7 @@ html:root.t_light {
.hz-chat-dock {
position: sticky;
bottom: 0;
z-index: 5;
z-index: var(--z-raised);
/* Clear the iOS home indicator when Safari's bottom bar hides (viewport-fit=cover
exposes the inset; 0 on devices without one, so no effect elsewhere). */
padding-bottom: env(safe-area-inset-bottom);
@@ -428,18 +466,27 @@ html:root.t_light {
from the per-theme --hz-elevation-* tokens (light AND dark aware). Brand-neutral:
the shadow is monochrome and color stays token-driven, so lux/zoo/pars theme
cleanly. One place defines the ladder; an overlay wears a class. `className`
forwards to the DOM node on web, so a Gui surface can wear these. */
.hz-elevation-1 { box-shadow: var(--hz-elevation-1); }
.hz-elevation-2 { box-shadow: var(--hz-elevation-2); }
.hz-elevation-3 { box-shadow: var(--hz-elevation-3); }
.hz-elevation-4 { box-shadow: var(--hz-elevation-4); }
.hz-elevation-5 { box-shadow: var(--hz-elevation-5); }
forwards to the DOM node on web, so a Gui surface can wear these.
Each selector is written `:root .hz-x.hz-x` on purpose. @hanzo/gui (Tamagui)
compiles its own shadow props to an atomic rule it injects at RUNTIME as
`:root ._bxsh-…` — specificity (0,2,0). A plain `.hz-paper` is (0,1,0) and loses;
`.hz-paper.hz-paper` merely TIES, and a tie is settled by stylesheet order, which
runtime injection makes nondeterministic. It lost in practice: an overlay wearing
`hz-paper` rendered Tamagui's `0 12px 24px rgba(0,0,0,.33)` instead of this ladder,
and on the true-black canvas that shadow is nearly invisible — the sheet did not
lift off the page. (0,3,0) wins outright, in either order, with no `!important`. */
:root .hz-elevation-1.hz-elevation-1 { box-shadow: var(--hz-elevation-1); }
:root .hz-elevation-2.hz-elevation-2 { box-shadow: var(--hz-elevation-2); }
:root .hz-elevation-3.hz-elevation-3 { box-shadow: var(--hz-elevation-3); }
:root .hz-elevation-4.hz-elevation-4 { box-shadow: var(--hz-elevation-4); }
:root .hz-elevation-5.hz-elevation-5 { box-shadow: var(--hz-elevation-5); }
/* Paper = an elevated sheet: hairline ring + top highlight + a mid cast shadow, so
a menu/palette/dialog reads as a physical sheet floating above the page. */
.hz-paper { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-3); }
.hz-paper-4 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-4); }
.hz-paper-5 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-5); }
:root .hz-paper.hz-paper { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-3); }
:root .hz-paper-4.hz-paper-4 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-4); }
:root .hz-paper-5.hz-paper-5 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-5); }
/* Overlay entrance — a fast, physical scale-fade from the origin (menus, palette,
dialog, support sheet). 180ms ease-out enter; the overlay's own unmount handles
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* Per-user `hk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
* Per-user `sk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
* API-keys "sign in to manage API keys" / CORS crack).
*
* The browser calls this OWN-origin route (`/keys`) with just its first-party
@@ -7,19 +7,19 @@
* (`resolveUser`) and mints/reads/revokes the key through IAM as the confidential
* `hanzo-console` client (`identity.ts` `mintUserKey`/`getUserKey`/`revokeUserKey`,
* over IAM `mint-user-keys`/`get-user`/`revoke-user-keys` — the WORKING key path,
* verified live). No credential ever reaches the browser; the `hk-` secret is
* verified live). No credential ever reaches the browser; the `sk-` secret is
* returned ONLY by POST (show once).
*
* Why not `cloud.hanzo.ai/v1/iam/keys` (the old path): that is a DIFFERENT
* ORIGIN than console.hanzo.ai, so a browser `fetch` is blocked by CORS ("Failed to
* fetch") — and cloud-api's own keys handler 501s ("IAM client unset") on this
* deployment anyway. The IAM confidential-client mint the console already uses for
* `hk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
* `sk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
* always-working path — so the Org-Settings API-keys surface uses it too (DRY: the
* exact primitives from `identity.ts`, no new IAM plumbing).
*
* GET → { hasKey, keyPrefix, createdAt } (no secret)
* POST → { accessKey } (mint/rotate; full hk- shown ONCE)
* POST → { accessKey } (mint/rotate; full sk- shown ONCE)
* DELETE → { ok: true } (revoke; the old key stops working)
*/
import { type NextRequest, NextResponse } from 'next/server'
@@ -57,7 +57,7 @@ export async function GET(req: NextRequest) {
}
}
/** POST — mint (or rotate) the key. Returns the full `hk-` secret ONCE. */
/** POST — mint (or rotate) the key. Returns the full `sk-` secret ONCE. */
export async function POST(req: NextRequest) {
// CSRF: minting mutates (and is billable-adjacent) from the auto-sent cookie —
// refuse a cross-origin request before any work.
+13 -1
View File
@@ -4,6 +4,11 @@ import '@hanzogui/core/reset.css'
// source of truth. Imported BEFORE globals.css so the console's Tamagui theme can
// derive its ladder from the design neutral/semantic tokens.
import './design/index.css'
// The motion/skeleton classes `@hanzo/ui/product` components emit (`skeleton`,
// `row`, `tnum`, `fade-up`, `drag`). Console's own markup still names the `hz-`
// prefixed twins in globals.css below; these are the package's, and without this
// import a DataTable's skeleton, row hover and tabular figures render unstyled.
import '@hanzo/ui/styles/motion.css'
import './globals.css'
import type { Metadata, Viewport } from 'next'
@@ -20,12 +25,19 @@ import { resolveConfig } from '~/config'
// The visible shell resolves the brand client-side from window.location, but the
// tab title is server-rendered — without reading the Host header here the browser
// tab leaks "Hanzo Cloud Console" on Lux/Zoo hosts, a white-label violation.
//
// The description is the same metadata read by the same brand, so it resolves the
// same way. It did not, and shipped `content="Unified admin console for Hanzo Cloud
// and all cloud products."` to console.lux.cloud and console.zoo.cloud — the title
// beside it was already correct, which is exactly why nobody noticed. Every
// brand-visible string in this function comes from `brandName`; adding a literal
// here re-opens the leak.
export async function generateMetadata(): Promise<Metadata> {
const host = (await headers()).get('host') ?? undefined
const { brandName } = resolveConfig(host)
return {
title: `${brandName} Console`,
description: 'Unified admin console for Hanzo Cloud and all cloud products.',
description: `Unified admin console for ${brandName} and all cloud products.`,
}
}
+11 -16
View File
@@ -1,6 +1,6 @@
/**
* Same-origin proxy to the cloud ML/training surface on hanzoai/ai (`/v1/train/*`,
* `/v1/ml/models`, and the fine-tuning broker `/v1/finetune/*`).
* Same-origin proxy to the cloud ML/training surface (`/v1/ml/models` and the
* fine-tuning broker `/v1/finetune/*`).
*
* The console's Training page calls its OWN origin (`/training/...`) with just the
* first-party session cookie; this server handler resolves the signed-in user from
@@ -11,16 +11,16 @@
* this is user-scoped (resolveUser), NOT the control-plane admin gate the `/paas`
* proxy uses. The cloud backend resolves the org from the token's `owner` claim (and
* the X-Org-Id the plain-REST train sub-service reads), so a caller can only ever
* touch their own org's jobs. `POST /v1/train/jobs` is billing-gated by the live
* ResourceMeter and returns 402 on an unfunded org — that status flows straight back
* so the UI can surface it honestly.
* touch their own org's jobs. `POST /v1/finetune/jobs` is billing-gated upstream and
* returns 402 on an unfunded org — that status flows straight back so the UI can
* surface it honestly.
*
* Why a Bearer and NOT the cookie (the fix for the "Not enabled" 403): cloud-api's
* `/v1/train/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
* `/v1/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
* principal" for a cookie-only call — the raw casibase session cookie is NOT a
* principal it accepts (only the sanitizer's cookie-token names or a Bearer). Minting
* the same user-bound token the `/v1` proxy uses is the ONE way a signed-in tenant
* reaches the train surface; the cookie is deliberately dropped upstream (it can't
* reaches this surface; the cookie is deliberately dropped upstream (it can't
* authenticate, and a cookie + JWT together risks the public-gateway 431).
*
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
@@ -44,14 +44,9 @@ const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
const ALLOWED = new Set([
// mlsvc — the canonical training surface (task #40 ResourceMeter gates POST jobs).
'train/jobs',
'train/experiments',
// Model serving — the org's deployed kserve InferenceServices.
'ml/models',
// Real Kubeflow control-plane probe (which operators/CRDs are actually served).
// Read-only; 503 + body flows through so the UI can report a degraded plane.
'train/health',
// fine-tuning broker (custom-data runs, HF search) — sibling surface.
// fine-tuning broker (custom-data runs, HF search) — the ONE training door.
'finetune/jobs',
'finetune/job',
'finetune/cancel',
@@ -68,7 +63,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
}
// CSRF: `POST /train/jobs` mutates (and bills) from the auto-sent cookie — refuse a
// CSRF: `POST /finetune/jobs` mutates (and bills) from the auto-sent cookie — refuse a
// cross-origin one before any work (safe reads pass).
const csrf = csrfRefusal(req, 'casibase')
if (csrf) return csrf
@@ -82,7 +77,7 @@ async function forward(req: NextRequest, path: string[]): Promise<NextResponse>
}
// Mint a short-lived, user-bound Bearer (the SAME per-user cache the `/v1`
// proxy uses). cloud-api's `/v1/train/*` 403s a cookie-only call ("no validated
// proxy uses). cloud-api's `/v1/*` 403s a cookie-only call ("no validated
// principal"); a Bearer is the one credential it accepts. Fail CLOSED with 502 if
// the token can't be minted — never fall through to an unauthenticated forward.
let bearer: string
+2 -2
View File
@@ -10,8 +10,8 @@
* billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …) — they differ at
* the FIRST path segment, so the tab slugs fall through to the SPA.
*
* Verbs: GET (reads: balance/usage/invoices/subscriptions/payment-methods, and the
* per-invoice PDF), POST (writes: top-up, spend-alerts, save-a-method, cancel/
* Verbs: GET (reads: balance/usage/invoices/subscriptions/methods, and the
* per-invoice PDF), POST (writes: top-up, alerts, save-a-method, cancel/
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
* server-side; a mutating verb is CSRF-guarded (`forwardBilling`).
+6 -6
View File
@@ -57,7 +57,7 @@ const TIMEOUT_MS = Number(process.env.NODES_RPC_TIMEOUT_MS ?? 8000)
/** A single allowlisted luxd JSON-RPC call. `path` and `method` are fixed here. */
async function rpc<T>(
host: string,
path: '/ext/bc/P' | '/ext/info',
path: '/v1/bc/P' | '/v1/info',
method: string,
signal: AbortSignal,
): Promise<T> {
@@ -94,11 +94,11 @@ async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
try {
const [valR, peerR, verR, hgtR, chainR] = await Promise.allSettled([
rpc<{ validators?: RawValidator[] }>(host, '/ext/bc/P', 'platform.getCurrentValidators', ctrl.signal),
rpc<{ numPeers?: string; peers?: RawPeer[] }>(host, '/ext/info', 'info.peers', ctrl.signal),
rpc<{ version?: string }>(host, '/ext/info', 'info.getNodeVersion', ctrl.signal),
rpc<{ height?: string }>(host, '/ext/bc/P', 'platform.getHeight', ctrl.signal),
rpc<{ blockchains?: RawBlockchain[] }>(host, '/ext/bc/P', 'platform.getBlockchains', ctrl.signal),
rpc<{ validators?: RawValidator[] }>(host, '/v1/bc/P', 'platform.getCurrentValidators', ctrl.signal),
rpc<{ numPeers?: string; peers?: RawPeer[] }>(host, '/v1/info', 'info.peers', ctrl.signal),
rpc<{ version?: string }>(host, '/v1/info', 'info.getNodeVersion', ctrl.signal),
rpc<{ height?: string }>(host, '/v1/bc/P', 'platform.getHeight', ctrl.signal),
rpc<{ blockchains?: RawBlockchain[] }>(host, '/v1/bc/P', 'platform.getBlockchains', ctrl.signal),
])
const reachable =
+9
View File
@@ -0,0 +1,9 @@
// TypeScript 7 reports TS2882 for a side-effect import with no type
// declaration ("Cannot find module or type declarations for side-effect import
// of './globals.css'"). TS 5.x let these pass silently.
//
// Next.js resolves stylesheet imports through its own loader pipeline, so these
// specifiers never reach the TypeScript module resolver at build time. The
// ambient declaration exists to tell the checker they are legitimate, not to
// give them a shape — hence no exported members.
declare module '*.css';
Vendored
+5
View File
@@ -0,0 +1,5 @@
// Side-effect CSS imports (`import './globals.css'`, `import '@hanzogui/core/reset.css'`).
// The bundler owns them; TypeScript only needs to know the specifier resolves.
// TS7 (tsgo) errors on an unresolvable side-effect import (TS2882) where tsc stayed
// silent, so the declaration lives here — one place, every stylesheet.
declare module '*.css'
+5 -4
View File
@@ -1,9 +1,10 @@
# Unified `/v1` backend endpoints
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`, the
casibase API). Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
credentials; responses are the envelope `{ status, msg, data, data2 }` (`data2`
is the total row count on list endpoints).
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`).
Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
credentials; responses are the envelope `{ status, msg, data, total }` (`total`
is the row count on list endpoints; the legacy `data2` count is still accepted
as a fallback until every emitter finishes the rename).
Client modules live in `src/lib/api/`.
+24 -8
View File
@@ -27,12 +27,24 @@ export type SessionClaims = {
email?: string
displayName?: string
isAdmin?: boolean
/** The IAM user's property bag — where `hanzo.preferences` rides as a SNAPSHOT. */
properties?: Record<string, string>
/** When the token was minted (`iat`, seconds). Defaults to now; set it in the past
* to reproduce the production case where the snapshot predates a later write. */
issuedAt?: number
}
const b64 = (o: object): string => Buffer.from(JSON.stringify(o)).toString('base64')
/**
* base64URL — what a JWT segment actually is. Plain base64 was close enough while the
* payloads were tiny, but `+` and `/` appear as soon as one grows (a `properties` bag
* is enough), and a strict decoder rejects the token outright: the SDK reports signed
* out and the app sits on its loader forever.
*/
const b64 = (o: object): string =>
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
/** The default identity render specs run as — a hanzo-org admin. */
export const DEFAULT_CLAIMS: Required<SessionClaims> = {
export const DEFAULT_CLAIMS: Required<Omit<SessionClaims, 'properties' | 'issuedAt'>> = {
owner: 'hanzo',
name: 'z',
email: 'z@hanzo.ai',
@@ -40,20 +52,24 @@ export const DEFAULT_CLAIMS: Required<SessionClaims> = {
isAdmin: true,
}
/** An unsigned JWT whose payload carries the claims + a far-future `exp`. */
export function forgeToken(claims: Required<SessionClaims>): string {
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, exp: Math.floor(Date.now() / 1000) + 3600 }
/** An unsigned JWT whose payload carries the claims, an `iat` and a far-future `exp`. */
export function forgeToken(claims: SessionClaims): string {
const iat = claims.issuedAt ?? Math.floor(Date.now() / 1000)
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, iat, exp: iat + 86_400 }
return `${b64({ alg: 'none' })}.${b64(payload)}.x`
}
/** Seed tokens + gate keys and register the IAM endpoint mocks. */
export async function primeSession(page: Page, overrides: Partial<SessionClaims> = {}): Promise<void> {
const claims: Required<SessionClaims> = { ...DEFAULT_CLAIMS, ...overrides }
const claims: SessionClaims = { ...DEFAULT_CLAIMS, ...overrides }
await page.addInitScript(
({ org, token }: { org: string; token: string }) => {
try {
sessionStorage.setItem('hanzo_iam_access_token', token)
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
// localStorage, not sessionStorage: the `@hanzo/iam` token store is shared
// across tabs (that IS the session), so seeding a per-tab area would leave
// the SDK reading an empty store and every primed spec signed out.
localStorage.setItem('hanzo_iam_access_token', token)
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
localStorage.setItem('hanzo.console.org', org)
localStorage.setItem('hanzo.console.org.selected', '1')
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
+269
View File
@@ -0,0 +1,269 @@
/**
* The ONE account control, at the foot of the rail — and the ONE org switch.
*
* There used to be three account-ish menus: an org switcher at the top of the
* sidebar, an account popover at the bottom, and a third in the mobile drawer,
* with four ways to sign out between them. They became one control that answered
* BOTH "who am I" and "where am I".
*
* They have now been split again, but by QUESTION rather than by accident: the
* account control at the foot answers who you are (identity, team, personal
* settings, balance, the way out), and `ContextSwitcher` at the TOP-LEFT answers
* where you are (organization + project, together, beside the tenant's mark).
* So the cross-tenant reach is asserted against the context switcher below, and
* the account menu is asserted to no longer offer a tenant at all.
*
* Everything is asserted on computed style and geometry. The failure this guards
* against is a menu that is present in the DOM and unreadable — a library that
* paints with utility class names renders exactly that in this app, because
* Tailwind never scanned node_modules. An `expect(locator).toBeVisible()` would
* have passed on the broken build.
*/
import { test, expect, type Page } from '@playwright/test'
import { primeSession } from './_session'
/** The cross-tenant org list an admin console reaches — none of them memberships. */
const ORGS = [
{ owner: 'admin', name: 'hanzo', displayName: 'Hanzo' },
{ owner: 'admin', name: 'maxpower', displayName: 'Max Power' },
{ owner: 'admin', name: 'acme-industrial', displayName: 'Acme Industrial' },
]
/** Every org-scoped request the page made, with the scope it carried. */
type Scoped = { url: string; org: string | null }
/**
* The account trigger in the persistent rail.
*
* The shell mounts the SAME control three times — the rail, the collapsed-rail
* hover flyout, and the phone drawer — because `SidebarNav` is one component with
* three mounts. All three stay in the DOM (the flyout and drawer are offset, not
* unmounted), which predates this change and belongs to the shell lane; the first
* in document order is the persistent rail, and every geometry assertion below
* checks it really is the one on screen.
*/
const accountTrigger = (page: Page) => page.getByTestId('nav-user').first()
/** The trigger inside the phone's account sheet — the last mount in the document. */
const drawerTrigger = (page: Page) => page.getByTestId('nav-user').last()
/** The org + project control at the top-left — the only thing that switches tenant. */
const contextTrigger = (page: Page) => page.getByTestId('switcher-context').first()
async function mountConsole(page: Page, seen: Scoped[]) {
// The standalone console reaches the cross-tenant list through its own gated
// `/admin/iam` proxy; the go:embed build reaches cloud's `/v1/iam` directly.
// Both are covered so the spec does not silently pass on the wrong one.
await page.route(/\/(v1|admin\/iam)\//, async (route) => {
const url = route.request().url()
seen.push({ url, org: route.request().headers()['x-org-id'] ?? null })
if (url.includes('get-organizations')) {
const query = new URL(url).searchParams.get('value') ?? ''
const rows = ORGS.filter((o) => o.displayName.toLowerCase().includes(query.toLowerCase()))
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', data: rows, data2: rows.length }) })
}
if (url.includes('billing/balance')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ spendableCents: 4250 }) })
}
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', data: [] }) })
})
// The reserved `admin` org IS the super admin — the only identity that reaches
// every tenant, which is what an admin console is for.
// Record every write to the console's org scope. The switch reloads the page and
// the harness re-seeds the scope on load, so the write is observed as it happens.
await page.addInitScript(() => {
const setItem = Storage.prototype.setItem
Storage.prototype.setItem = function (key: string, value: string) {
if (key === 'hanzo.console.org') {
const log = JSON.parse(sessionStorage.getItem('spec.scope.writes') ?? '[]') as string[]
log.push(`${key}=${value}`)
setItem.call(sessionStorage, 'spec.scope.writes', JSON.stringify(log))
}
return setItem.call(this, key, value)
}
})
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin' })
await page.goto('/')
await page.waitForSelector('[data-testid=nav-user]', { state: 'attached', timeout: 30_000 })
}
const px = (v: string) => Number.parseFloat(v)
const rgb = (v: string) => (v.match(/\d+(\.\d+)?/g) ?? []).map(Number)
const luminance = ([r, g, b]: number[]) => {
const f = (c: number) => { const n = c / 255; return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4 }
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
const contrast = (a: number[], b: number[]) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((m, n) => n - m)
return (hi + 0.05) / (lo + 0.05)
}
test.describe('account control', () => {
test('sits at the foot of the rail and paints in a shell with no Tailwind', async ({ page }) => {
const seen: Scoped[] = []
await mountConsole(page, seen)
// The control is at the BOTTOM — below the middle of the sidebar, not above it.
const trigger = accountTrigger(page)
const box = (await trigger.boundingBox())!
const viewport = page.viewportSize()!
expect(box.y).toBeGreaterThan(viewport.height / 2)
expect(box.x).toBeLessThan(300)
// Nothing else claims to switch orgs: the top-of-rail switcher is gone.
await expect(page.getByLabel('Switch organization')).toHaveCount(0)
await trigger.click()
const menu = page.locator('[role=menu]')
await menu.waitFor()
const paint = await menu.evaluate((el) => {
const s = getComputedStyle(el)
const r = el.getBoundingClientRect()
return {
bg: s.backgroundColor,
radius: s.borderTopLeftRadius,
borderWidth: s.borderTopWidth,
z: s.zIndex,
font: s.fontFamily,
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
}
})
// It PAINTS — an opaque surface, not a transparent stack of divs.
expect(paint.bg).not.toBe('rgba(0, 0, 0, 0)')
expect(px(paint.radius)).toBeGreaterThanOrEqual(8)
expect(px(paint.borderWidth)).toBeGreaterThanOrEqual(1)
// …in the app's own typeface, not a system fallback.
expect(paint.font).toMatch(/Geist/i)
// It is FULLY on screen and above the shell.
expect(paint.rect.x).toBeGreaterThanOrEqual(0)
expect(paint.rect.y).toBeGreaterThanOrEqual(0)
expect(paint.rect.x + paint.rect.w).toBeLessThanOrEqual(viewport.width + 1)
expect(paint.rect.y + paint.rect.h).toBeLessThanOrEqual(viewport.height + 1)
// Nothing of the shell is painted over it.
const onTop = await menu.evaluate((el) => {
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + 12)
return el.contains(hit)
})
expect(onTop).toBe(true)
// Rows are padded, tall enough to hit, and readable.
const rows = await menu.locator('.hz-iam-row').evaluateAll((els) =>
els.map((el) => {
const s = getComputedStyle(el)
return { pl: s.paddingLeft, h: el.getBoundingClientRect().height, color: s.color, text: (el.textContent ?? '').trim() }
}),
)
expect(rows.length).toBeGreaterThanOrEqual(5)
for (const row of rows) {
expect(px(row.pl), `"${row.text}" padding`).toBeGreaterThanOrEqual(8)
expect(row.h, `"${row.text}" height`).toBeGreaterThanOrEqual(24)
expect(contrast(rgb(row.color), rgb(paint.bg)), `"${row.text}" contrast`).toBeGreaterThanOrEqual(4.5)
}
// Hover is a real state — the switch-that-rendered-identical class of bug.
const first = menu.locator('.hz-iam-row').first()
const atRest = await first.evaluate((el) => getComputedStyle(el).backgroundColor)
await first.hover()
expect(await first.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(atRest)
await page.screenshot({ path: 'e2e-shots/account-menu-desktop.png', animations: 'disabled' })
})
test('nothing in it shouts', async ({ page }) => {
await mountConsole(page, [])
await accountTrigger(page).click()
await page.locator('[role=menu]').waitFor()
const shouting = await page.locator('[role=menu]').evaluate((el) =>
[...el.querySelectorAll('*')].filter((n) => getComputedStyle(n).textTransform === 'uppercase').map((n) => n.textContent ?? ''),
)
expect(shouting).toEqual([])
const typedInCaps = await page.locator('[role=menu]').evaluate((el) =>
[...el.querySelectorAll('*')]
.map((n) => (n.children.length ? '' : (n.textContent ?? '').trim()))
.filter((t) => /^[A-Z][A-Z0-9 &/·—-]{3,}$/.test(t)),
)
expect(typedInCaps).toEqual([])
})
test('the context switcher reaches a tenant the caller is not a member of', async ({ page }) => {
const seen: Scoped[] = []
await mountConsole(page, seen)
// Tenancy is the TOP-LEFT control's job now, not the account menu's.
await contextTrigger(page).click()
// Acme is nobody's membership — it exists only in the cross-tenant list an
// admin may search. A memberships-only switcher could not offer it at all.
await page.getByLabel('Find an organization').fill('acme')
// `radiogroup`/`radio`, not `listbox`/`option`: @hanzo/gui's `role` union is
// React Native's a11y set, which carries `option` but NOT `listbox`.
const orgList = page.getByRole('radiogroup', { name: 'Organizations' })
const acme = orgList.getByRole('radio', { name: 'Acme Industrial' })
await acme.waitFor()
// Scoped to the ORG group — the same popover also lists projects, and a bare
// getByRole('radio') would silently count those too.
await expect(orgList.getByRole('radio')).toHaveCount(1)
await page.screenshot({ path: 'e2e-shots/account-menu-find-org.png', animations: 'disabled' })
// MONEY PATH. Entering a tenant must go through the console's OWN org scope —
// the single seam that persists `hanzo.console.org`, reloads, and is read back
// as `X-Org-Id` on every call. A switcher that minted its own would bypass the
// scoping and its billing attribution without anything visibly breaking, so the
// write itself is what is asserted. (The scope key is recorded through a wrapped
// setter because the reload re-runs the harness's own seeding.)
await acme.click()
await page.waitForFunction(
() => sessionStorage.getItem('spec.scope.writes')?.includes('acme-industrial') ?? false,
)
const writes: string[] = JSON.parse(
(await page.evaluate(() => sessionStorage.getItem('spec.scope.writes'))) ?? '[]',
)
expect(writes).toContain('hanzo.console.org=acme-industrial')
// And every scoped call the page made before that carried the admin's own
// scope — the menu never issued a request under someone else's tenant.
for (const call of seen.filter((s) => s.org !== null)) expect(call.org).toBe('admin')
})
test('the same control is the account surface on a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mountConsole(page, [])
// On a phone the rail is a drawer, so the account control lives in the
// right-hand account sheet — the SAME component, not a phone-only copy.
await page.getByLabel('Account and settings').click()
await drawerTrigger(page).click()
const menu = page.locator('[role=menu]')
await menu.waitFor()
const rect = await menu.evaluate((el) => {
const r = el.getBoundingClientRect()
return { x: r.x, y: r.y, w: r.width, h: r.height, bg: getComputedStyle(el).backgroundColor }
})
expect(rect.bg).not.toBe('rgba(0, 0, 0, 0)')
// …and it paints OVER the sheet it was opened from. A sheet pinned at a
// literal 1000 swallowed the menu whole: present, measurable, unclickable.
const onTop = await menu.evaluate((el) => {
const r = el.getBoundingClientRect()
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + 12))
})
expect(onTop).toBe(true)
expect(rect.x).toBeGreaterThanOrEqual(0)
expect(rect.x + rect.w).toBeLessThanOrEqual(391)
expect(rect.y).toBeGreaterThanOrEqual(0)
expect(rect.y + rect.h).toBeLessThanOrEqual(845)
// The page itself never scrolls sideways to accommodate it.
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
await page.screenshot({ path: 'e2e-shots/account-menu-mobile.png', animations: 'disabled' })
})
})
+355
View File
@@ -0,0 +1,355 @@
/**
* Infrastructure admin board — render + interaction proof.
*
* Drives the REAL InfraModule (client + pure logic + the shared sortable DataTable)
* against a mock of `/v1/admin/infra` seeded with the fleet's REAL shape: 58 nodes,
* 295 volumes, 8 clusters, 132 detached, and 3 unreferenced/deletable volumes totalling
* 500 GiB ≈ $50/mo. Nothing here is fabricated beyond the fixture — the assertions are
* about what the board DOES with real numbers.
*
* Proves: the Overview totals render and the droplet-local-disk note is unmissable;
* every tab renders; sorting a column genuinely REORDERS rows (the first row's text
* changes); the `unreferenced` filter yields exactly 3; a NON-deletable volume shows no
* delete control (it shows its blockedReason instead); and a deletable volume's confirm
* states the name, the size in GiB, and the monthly cost being reclaimed.
*
* Screenshots every tab to e2e-shots/admin-infra-<tab>.png.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test admin-infra
*/
import { test, expect, type Route } from '@playwright/test'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
// ── the fixture: the fleet's REAL shape ───────────────────────────────────────
const CLUSTER_NAMES = ['hanzo-k8s', 'lux-k8s', 'zoo-k8s', 'bootnode-k8s', 'pars-k8s', 'ci-arc-k8s', 'edge-k8s', 'staging-k8s']
/** 8 clusters. `zebra-k8s` is deliberately absent — name sorting is proven on the real set. */
const clusters = CLUSTER_NAMES.map((name, i) => ({
id: `c-${i + 1}`,
name,
region: ['nyc3', 'sfo3', 'ams3'][i % 3],
version: '1.31.1-do.4',
status: 'running',
nodePools: 2 + (i % 3),
nodes: [12, 10, 8, 7, 6, 6, 5, 4][i],
pods: 120 - i * 9,
pvs: 40 - i * 3,
pvcs: 40 - i * 3,
idlePVCs: i === 0 ? 6 : i === 1 ? 3 : 0,
scanned: true,
scanError: '',
monthlyCents: [480000, 320000, 180000, 120000, 74000, 60000, 32000, 18000][i],
}))
/** 58 droplets across the 8 clusters; each carries 160 GiB of LOCAL disk (9,280 GiB total). */
const nodes = Array.from({ length: 58 }, (_, i) => ({
id: 1000 + i,
name: `pool-${String.fromCharCode(97 + (i % 8))}-${i + 1}`,
cluster: CLUSTER_NAMES[i % 8],
clusterId: `c-${(i % 8) + 1}`,
region: ['nyc3', 'sfo3', 'ams3'][i % 3],
status: 'active',
sizeSlug: i % 5 === 0 ? 's-8vcpu-16gb' : 's-4vcpu-8gb',
vcpus: i % 5 === 0 ? 8 : 4,
memoryMiB: i % 5 === 0 ? 16384 : 8192,
localDiskGiB: 160,
monthlyCents: i % 5 === 0 ? 9600 : 4800,
createdAt: '2026-01-04T10:00:00Z',
privateIp: `10.0.${Math.floor(i / 256)}.${i % 256}`,
publicIp: '',
tags: ['k8s', `k8s:c-${(i % 8) + 1}`],
ready: i !== 57,
schedulable: i !== 56,
pods: 4 + (i % 17),
volumes: i % 3 === 0 ? 2 : 1,
}))
/**
* 295 volumes: 163 attached, 129 detached-but-referenced (bound/released), and the 3
* UNREFERENCED ones that are genuinely reclaimable (500 GiB ≈ $50/mo).
* detachedVolumes = 132 = the 129 bound/released + the 3 unreferenced.
*/
const volumes = [
...Array.from({ length: 163 }, (_, i) => ({
id: `v-att-${i}`,
name: `pvc-attached-${String(i).padStart(3, '0')}`,
region: 'nyc3',
sizeGiB: 100,
monthlyCents: 1000,
state: 'attached',
dropletIds: [1000 + (i % 58)],
nodeName: nodes[i % 58].name,
cluster: CLUSTER_NAMES[i % 8],
clusterId: `c-${(i % 8) + 1}`,
tagCluster: `c-${(i % 8) + 1}`,
pv: `pv-att-${i}`,
pvPhase: 'Bound',
pvcNamespace: 'hanzo',
pvcName: `data-${i}`,
mountedBy: [`pod-${i}`],
idle: false,
createdAt: '2026-02-01T00:00:00Z',
deletable: false,
blockedReason: 'Attached to a droplet.',
})),
...Array.from({ length: 118 }, (_, i) => ({
id: `v-bound-${i}`,
name: `pvc-bound-${String(i).padStart(3, '0')}`,
region: 'sfo3',
sizeGiB: 150,
monthlyCents: 1500,
state: 'bound',
dropletIds: [],
nodeName: '',
cluster: CLUSTER_NAMES[i % 8],
clusterId: `c-${(i % 8) + 1}`,
tagCluster: `c-${(i % 8) + 1}`,
pv: `pv-bound-${i}`,
pvPhase: 'Bound',
pvcNamespace: 'hanzo',
pvcName: `idle-${i}`,
mountedBy: [],
idle: true,
createdAt: '2026-02-01T00:00:00Z',
deletable: false,
blockedReason: 'Bound to PVC hanzo/idle — still claimed.',
})),
...Array.from({ length: 11 }, (_, i) => ({
id: `v-rel-${i}`,
name: `pvc-released-${String(i).padStart(3, '0')}`,
region: 'ams3',
sizeGiB: 120,
monthlyCents: 1200,
state: 'released',
dropletIds: [],
nodeName: '',
cluster: CLUSTER_NAMES[i % 8],
clusterId: `c-${(i % 8) + 1}`,
tagCluster: `c-${(i % 8) + 1}`,
pv: `pv-rel-${i}`,
pvPhase: 'Released',
pvcNamespace: '',
pvcName: '',
mountedBy: [],
idle: false,
createdAt: '2026-01-15T00:00:00Z',
deletable: false,
blockedReason: 'PV is Released but not yet reclaimed — retain policy holds the data.',
})),
// The 3 genuinely reclaimable volumes: 500 GiB total, $50.00/mo total.
{
id: 'v-orphan-1', name: 'pvc-abandoned-alpha', region: 'nyc3', sizeGiB: 200, monthlyCents: 2000,
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: 'c-1',
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
createdAt: '2025-11-02T00:00:00Z', deletable: true, blockedReason: '',
},
{
id: 'v-orphan-2', name: 'pvc-abandoned-bravo', region: 'sfo3', sizeGiB: 200, monthlyCents: 2000,
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: '',
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
createdAt: '2025-12-11T00:00:00Z', deletable: true, blockedReason: '',
},
{
id: 'v-orphan-3', name: 'pvc-abandoned-charlie', region: 'ams3', sizeGiB: 100, monthlyCents: 1000,
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: '',
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
createdAt: '2026-01-20T00:00:00Z', deletable: true, blockedReason: '',
},
]
const loadBalancers = [
{ id: 'lb-1', name: 'edge-ingress', region: 'nyc3', status: 'active', ip: '143.198.10.1', sizeUnit: 3, monthlyCents: 3600, droplets: 12, cluster: 'hanzo-k8s' },
{ id: 'lb-2', name: 'api-gateway', region: 'sfo3', status: 'active', ip: '143.198.10.2', sizeUnit: 1, monthlyCents: 1200, droplets: 10, cluster: 'lux-k8s' },
{ id: 'lb-3', name: 'zoo-edge', region: 'ams3', status: 'new', ip: '', sizeUnit: 1, monthlyCents: 1200, droplets: 0, cluster: 'zoo-k8s' },
{ id: 'lb-4', name: 'bootnode-rpc', region: 'nyc3', status: 'active', ip: '143.198.10.4', sizeUnit: 1, monthlyCents: 1200, droplets: 7, cluster: 'bootnode-k8s' },
]
const findings = [
{ id: 'f-1', severity: 'critical', kind: 'unreferenced-volume', title: 'Three unreferenced volumes', detail: '500 GiB of block storage is referenced by no PV, PVC or droplet.', resource: 'pvc-abandoned-alpha, pvc-abandoned-bravo, pvc-abandoned-charlie', cluster: '', monthlyCents: 5000 },
{ id: 'f-2', severity: 'warn', kind: 'idle-pvc', title: 'Idle PVCs on hanzo-k8s', detail: 'Bound to a PVC but no pod mounts them.', resource: '6 PVCs', cluster: 'hanzo-k8s', monthlyCents: 9000 },
{ id: 'f-3', severity: 'warn', kind: 'released-pv', title: 'Released PVs retained', detail: 'Retain reclaim policy is holding the data.', resource: '11 PVs', cluster: 'lux-k8s', monthlyCents: 13200 },
{ id: 'f-4', severity: 'info', kind: 'cost-outlier', title: 'hanzo-k8s is 28% of fleet spend', detail: 'Largest single cluster by monthly cost.', resource: 'hanzo-k8s', cluster: 'hanzo-k8s', monthlyCents: 480000 },
]
const snapshot = {
at: new Date().toISOString(),
complete: true,
incompleteReason: '',
sources: [
{ name: 'digitalocean', ok: true, rows: 359, error: '', at: new Date().toISOString() },
{ name: 'hanzo-k8s', ok: true, rows: 40, error: '', at: new Date().toISOString() },
],
totals: {
clusters: 8, nodes: 58, volumes: 295, loadBalancers: 4,
volumeGiB: 41200, attachedVolumes: 163, attachedGiB: 16300,
detachedVolumes: 132, detachedGiB: 20120,
unreferencedVolumes: 3, unreferencedGiB: 500,
idlePVCs: 118, localDiskGiB: 9280,
},
cost: { dropletsMonthly: 1284000, volumesMonthly: 412000, loadBalancersMonthly: 7200, totalMonthly: 1703200, reclaimableMonthly: 5000 },
clusters, nodes, volumes, loadBalancers, findings,
}
// ── the spec ──────────────────────────────────────────────────────────────────
/** Mock everything; `/v1/admin/infra` answers with the fixture, all else an empty envelope. */
async function mockFleet(page: import('@playwright/test').Page) {
await page.route('**/*', async (route: Route) => {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
const path = url.pathname
if (path === '/v1/admin/infra' && req.method() === 'GET') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: snapshot }) })
}
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(path)) return route.continue()
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
})
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', isAdmin: true })
}
/**
* Open one tab by URL. This also proves the registry declares the `:tab` route — an
* undeclared tab slug 404s (the v8.4.86 class of bug), which a click-only spec hides.
* URL navigation is also unambiguous: the sidebar carries its own "Clusters" / "Nodes"
* product entries, so a bare button match would be a coin flip.
*/
async function openTab(page: import('@playwright/test').Page, slug: string, tabLabel: string) {
await page.goto(`${BASE_URL}/infra${slug ? `/${slug}` : ''}`, { waitUntil: 'domcontentloaded' })
await expect(page.getByText('Infrastructure').first()).toBeVisible({ timeout: 30_000 })
// The module's own tab bar rendered this tab (and it is the selected one).
await expect(page.getByRole('button', { name: tabLabel, exact: true }).last()).toBeVisible({ timeout: 15_000 })
}
test('infrastructure board renders the fleet, sorts, filters, and gates deletion', async ({ page }) => {
mkdirSync(SHOTS, { recursive: true })
await mockFleet(page)
// ── Overview: the totals + the unmissable local-disk note ───────────────────
await openTab(page, '', 'Overview')
// Cost breakdown: total / droplets / block storage / load balancers / reclaimable.
await expect(page.getByText('$17,032.00').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('$12,840.00').first()).toBeVisible()
await expect(page.getByText('$4,120.00').first()).toBeVisible()
await expect(page.getByText('$72.00').first()).toBeVisible()
// The reclaimable card: the 3 unreferenced volumes ≈ $50/mo, 500 GiB.
await expect(page.getByText('$50.00').first()).toBeVisible()
await expect(page.getByText('3 unreferenced · 500 GiB').first()).toBeVisible()
// Fleet counts.
await expect(page.getByText('8 clusters · 58 nodes').first()).toBeVisible()
await expect(page.getByText('295 volumes · 40.2 TiB').first()).toBeVisible()
// THE distinction: droplet local disk is inside the droplet price, not block storage.
await expect(page.getByText('Droplet local disk is included in the droplet price — it is never billed separately')).toBeVisible()
await expect(page.getByText(/9,280 GiB of local disk is already inside the droplet number/)).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'admin-infra-overview.png'), fullPage: false })
// ── Clusters: sorting a column genuinely REORDERS rows ──────────────────────
await page.getByRole('button', { name: 'Clusters', exact: true }).first().click()
await expect(page.getByText('hanzo-k8s').first()).toBeVisible({ timeout: 15_000 })
// Default sort is Monthly desc → hanzo-k8s ($4,800.00) is first.
const clusterRows = page.locator('.hz-row')
await expect(clusterRows.first()).toContainText('hanzo-k8s')
const beforeSort = (await clusterRows.first().innerText()).trim()
// Click the "Cluster" header → sort by name ASC → bootnode-k8s is first (a different row).
await page.getByLabel('Sort by Cluster').click()
await expect(clusterRows.first()).toContainText('bootnode-k8s', { timeout: 10_000 })
const afterAsc = (await clusterRows.first().innerText()).trim()
expect(afterAsc).not.toBe(beforeSort) // the first row's text genuinely CHANGED
// Click it again → DESC → zoo-k8s is first (the reverse end of the same column).
await page.getByLabel('Sort by Cluster').click()
await expect(clusterRows.first()).toContainText('zoo-k8s', { timeout: 10_000 })
expect((await clusterRows.first().innerText()).trim()).not.toBe(afterAsc)
await page.screenshot({ path: join(SHOTS, 'admin-infra-clusters.png'), fullPage: false })
// ── Nodes: 58 droplets, sortable, with a cordon control ─────────────────────
await page.getByRole('button', { name: 'Nodes', exact: true }).first().click()
await expect(page.getByLabel('Sort by Node')).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('button', { name: 'Cordon' }).first()).toBeVisible()
// Sort by vCPU ascending → a 4-vCPU node leads; descending → an 8-vCPU node leads.
const nodeRows = page.locator('.hz-row')
await page.getByLabel('Sort by vCPU').click()
await expect(nodeRows.first()).toContainText('s-4vcpu-8gb', { timeout: 10_000 })
const nodeAsc = (await nodeRows.first().innerText()).trim()
await page.getByLabel('Sort by vCPU').click()
await expect(nodeRows.first()).toContainText('s-8vcpu-16gb', { timeout: 10_000 })
expect((await nodeRows.first().innerText()).trim()).not.toBe(nodeAsc)
await page.screenshot({ path: join(SHOTS, 'admin-infra-nodes.png'), fullPage: false })
// ── Volumes: the unreferenced filter yields EXACTLY 3 ───────────────────────
await page.getByRole('button', { name: 'Volumes', exact: true }).first().click()
await expect(page.getByText('295').first()).toBeVisible({ timeout: 15_000 })
await page.getByRole('button', { name: 'Unreferenced', exact: true }).click()
const volumeRows = page.locator('.hz-row')
await expect(volumeRows).toHaveCount(3, { timeout: 10_000 })
await expect(page.getByText('pvc-abandoned-alpha')).toBeVisible()
await expect(page.getByText('pvc-abandoned-bravo')).toBeVisible()
await expect(page.getByText('pvc-abandoned-charlie')).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'admin-infra-volumes.png'), fullPage: false })
// A DELETABLE volume: the confirm states name + GiB + the monthly cost reclaimed.
await page.getByText('pvc-abandoned-alpha').first().click()
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 })
const confirmText = page.getByText(/Delete volume “pvc-abandoned-alpha”/)
await expect(confirmText).toBeVisible()
await expect(confirmText).toContainText('200 GiB')
await expect(confirmText).toContainText('$20.00/month')
await expect(confirmText).toContainText('A snapshot is taken first')
await expect(page.getByRole('button', { name: 'Delete pvc-abandoned-alpha' })).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'admin-infra-volume-delete.png'), fullPage: false })
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 10_000 })
// A NON-deletable volume: NO delete control anywhere — the blocked reason instead.
await page.getByRole('button', { name: 'Attached', exact: true }).click()
await expect(page.getByText('pvc-attached-000').first()).toBeVisible({ timeout: 10_000 })
await page.getByText('pvc-attached-000').first().click()
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 })
await expect(page.getByText('This volume cannot be deleted')).toBeVisible()
await expect(page.getByText('Attached to a droplet.').first()).toBeVisible()
// The gate, asserted negatively: no delete button, no confirm text, no snapshot toggle.
await expect(page.getByRole('button', { name: /^Delete / })).toHaveCount(0)
await expect(page.getByText(/Delete volume “/)).toHaveCount(0)
await expect(page.getByText('Take a snapshot first')).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 10_000 })
// ── Load balancers ──────────────────────────────────────────────────────────
await page.getByRole('button', { name: 'Load balancers', exact: true }).first().click()
await expect(page.getByText('edge-ingress').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('$36.00').first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'admin-infra-load-balancers.png'), fullPage: false })
// ── Audit: findings grouped by severity, with cost impact ───────────────────
await page.getByRole('button', { name: 'Audit', exact: true }).first().click()
await expect(page.getByText('Three unreferenced volumes').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('critical · 1').first()).toBeVisible()
await expect(page.getByText('warn · 2').first()).toBeVisible()
await expect(page.getByText('info · 1').first()).toBeVisible()
// Group cost impact: the two warns sum to $222.00/mo (9000 + 13200 cents).
await expect(page.getByText('$222.00/mo').first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'admin-infra-audit.png'), fullPage: false })
})
+165
View File
@@ -0,0 +1,165 @@
/**
* e2e: the agent quickstart.
*
* The surface in the screenshot: a step ladder, "What do you want to build?" with a
* composer, and a searchable template gallery beside it. These are assertions only a
* browser can make — that the two columns actually paint side by side at desktop,
* stack on a phone without the body scrolling sideways, and that picking a template
* carries its preset into the builder.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test agent-quickstart
*/
import { test, expect, type Route, type Page } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const ACCOUNT = { owner: 'hanzo', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin', isAdmin: true }
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
const json = (route: Route, body: unknown, status = 200) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
/** Every backend 401s — this spec is about the SURFACE, not data. */
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (url.pathname.startsWith('/auth/')) return json(route, { ok: true })
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return json(route, { error: 'Sign in to use Hanzo Cloud.' }, 401)
}
async function open(page: Page) {
await page.route('**/*', mock)
await primeSession(page, ACCOUNT)
await page.goto(`${BASE_URL}/agents/quickstart`, { waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
await page.waitForTimeout(1500)
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('desktop: the ladder, the composer and the gallery', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page)
await expect(page.getByText('What do you want to build?')).toBeVisible()
await expect(page.getByLabel('Describe your agent')).toBeVisible()
await expect(page.getByText('Browse templates')).toBeVisible()
// Step 1 is current; later steps are present but not yet reachable.
await expect(page.getByRole('button', { name: /Step 1: Describe/ })).toBeVisible()
await expect(page.getByRole('button', { name: /Step 3: Run/ })).toBeDisabled()
// The two columns sit SIDE BY SIDE — geometry, not source.
const composer = await page.getByLabel('Describe your agent').boundingBox()
const gallery = await page.getByText('Browse templates').boundingBox()
expect(composer && gallery).toBeTruthy()
expect(gallery!.x, 'the gallery is to the right of the composer').toBeGreaterThan(composer!.x + composer!.width - 1)
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-desktop.png'), fullPage: false })
await ctx.close()
})
test('the gallery searches, and picking a template carries its preset into the builder', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page)
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Start from Code reviewer' })).toBeVisible()
await page.getByLabel('Search templates').fill('extract')
await page.waitForTimeout(400)
await expect(page.getByRole('button', { name: 'Start from Structured extractor' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Start from Deep researcher' })).toHaveCount(0)
await page.getByLabel('Search templates').fill('')
await page.waitForTimeout(300)
await page.getByRole('button', { name: 'Start from Deep researcher' }).click()
await page.waitForTimeout(700)
// Step 2: the ONE builder, carrying the template's preset — the handle and the
// prompt the template declares, not an empty form.
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
await expect(page.getByText(/You research questions/).first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-configure.png'), fullPage: false })
await ctx.close()
})
test('phone: it stacks and the body never scrolls sideways', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await open(page)
await expect(page.getByText('What do you want to build?')).toBeVisible()
await expect(page.getByLabel('Describe your agent')).toBeVisible()
const scrolls = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
)
expect(scrolls, 'body must not scroll horizontally').toBe(false)
await page.screenshot({ path: join(SHOTS, 'agent-quickstart-phone.png'), fullPage: true })
await ctx.close()
})
test('a template card is reachable and operable by keyboard, and it rings', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page)
const card = page.getByRole('button', { name: 'Start from Deep researcher' })
await card.focus()
await expect(card).toBeFocused()
// The focus law lives in globals.css and keys off [tabindex] among others — a card
// that takes focus and shows nothing is worse than one that cannot be reached.
const ring = await card.evaluate((el) => {
const s = getComputedStyle(el)
return { width: s.outlineWidth, style: s.outlineStyle, color: s.outlineColor }
})
expect(ring.style, 'the focused card draws an outline').not.toBe('none')
expect(parseFloat(ring.width), 'the outline has real width').toBeGreaterThan(0)
// Enter picks it — the same thing a click does.
await page.keyboard.press('Enter')
await page.waitForTimeout(700)
await expect(page.getByRole('button', { name: /Step 2: Configure/ })).toBeVisible()
await expect(page.locator('input[value="researcher"]').first()).toBeVisible()
await ctx.close()
})
test('the board\'s New Agent button is the SAME door as the quickstart', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await page.route('**/*', mock)
await primeSession(page, ACCOUNT)
await page.goto(`${BASE_URL}/agents`, { waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
await page.waitForTimeout(1200)
// Whichever New-Agent affordance the board is showing (header button or empty
// state), it must LAND on the quickstart — not open a second, differently-shaped
// create form in a side pane.
const cta = page.getByRole('button', { name: /New Agent/i }).filter({ visible: true }).first()
await cta.click()
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/agents/quickstart')
await expect(page.getByText('What do you want to build?')).toBeVisible()
await ctx.close()
})
+1 -1
View File
@@ -149,7 +149,7 @@ test.describe('(A) fixture render — model mix (fable-5 75%) + 62% margin + hon
// Page rendered (not the operator gate).
await expect(page.getByText('AI Economics').first()).toBeVisible()
await expect(page.locator('text=/Operator access required|not authorized|access denied/i')).toHaveCount(0)
await expect(page.locator('text=/SuperAdmin access required|not authorized|access denied/i')).toHaveCount(0)
// (a) model mix — the mocked rows WITH request-share %.
const modelMix = page.getByTestId('model-mix')
+170
View File
@@ -0,0 +1,170 @@
/**
* e2e: the assistant's ONE entry point, and the All-products directory you can act in.
*
* Three claims, each measured in a real browser rather than inferred from source:
*
* 1. The assistant opens from a FLOATING bottom-right control, not from the header —
* asserted on GEOMETRY (the control's box is in the bottom-right quadrant of the
* viewport) and on the header carrying no assistant control at all.
* 2. Clicking an app in the All-products directory NAVIGATES to that app. This is the
* regression that matters: the rows rendered, hovered, and did nothing, so the
* directory looked interactive and was not. Asserted on where the browser LANDS.
* 3. A pin made in the directory survives a reload EVEN WHEN the identity token
* carries an older preferences snapshot — the exact production condition (the
* token is minted at sign-in; a pin made after it is not in it).
*
* Local dev server + mocked network; `primeSession` supplies the IAM-PKCE identity.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test assistant-fab-and-apps
*/
import { test, expect, type Route, type Page } from '@playwright/test'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
})
}
/** Sign in and land on `path`, waiting for the signed-in shell to have mounted. */
async function boot(page: Page, path = '/', claims?: Parameters<typeof primeSession>[1]) {
await page.route('**/*', mock)
await primeSession(page, claims)
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('the assistant opens from the bottom-right, and the header carries no AI control', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
const box = await fab.boundingBox()
expect(box).not.toBeNull()
// Bottom-right quadrant: the whole point of the relocation.
expect(box!.x).toBeGreaterThan(1440 / 2)
expect(box!.y).toBeGreaterThan(900 / 2)
// A comfortable target, not a hairline.
expect(box!.width).toBeGreaterThanOrEqual(44)
expect(box!.height).toBeGreaterThanOrEqual(44)
// The topbar itself holds no assistant control any more — it used to carry two
// (a brand-H "Chat with Hanzo" and a "Talk to Hanzo" mic) beside the search box.
const inTopbar = await page.evaluate(() =>
Array.from(document.querySelectorAll('.hz-topbar [aria-label]')).map((n) => n.getAttribute('aria-label') ?? ''),
)
expect(inTopbar).not.toHaveLength(0) // the topbar was found at all
expect(inTopbar.filter((l) => /Hanzo/i.test(l))).toHaveLength(0)
await page.screenshot({ path: join(SHOTS, 'assistant-fab-desktop.png') })
// It opens the SAME assistant surface.
await fab.click()
await expect(page.getByText('Assistant').first()).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: join(SHOTS, 'assistant-open-desktop.png') })
await ctx.close()
})
test('the assistant control is reachable on a phone and never scrolls the body sideways', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await boot(page)
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
const box = await fab.boundingBox()
expect(box).not.toBeNull()
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
expect(box!.y).toBeGreaterThan(844 / 2)
const [scrollW, clientW] = await page.evaluate(() => [
document.documentElement.scrollWidth,
document.documentElement.clientWidth,
])
expect(scrollW).toBe(clientW)
await page.screenshot({ path: join(SHOTS, 'assistant-fab-mobile.png') })
await ctx.close()
})
test('clicking an app in All products opens that app', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await page.getByRole('button', { name: 'All products' }).first().click()
const row = page.getByRole('button', { name: 'Open Agents' })
await expect(row).toBeVisible({ timeout: 15_000 })
await page.screenshot({ path: join(SHOTS, 'all-products-desktop.png') })
await row.click()
// Where the browser LANDS is the claim — not that a handler fired.
await expect(page).toHaveURL(/\/agents$/, { timeout: 15_000 })
await ctx.close()
})
test('a pin made in All products survives a reload under a STALE token snapshot', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
// The production condition: the identity token was minted an hour ago and carries a
// preferences SNAPSHOT from then. Treating that snapshot as authoritative is what
// silently threw away every pin made since — the pin reads as pinned, and is gone
// after a reload.
const snapshot = { pins: [{ id: 'models', group: '' }], pinGroups: [] }
await boot(page, '/', {
properties: { 'hanzo.preferences': JSON.stringify(snapshot) },
issuedAt: Math.floor(Date.now() / 1000) - 3600,
})
const openDirectory = async () => {
await page.getByRole('button', { name: 'All products' }).first().click()
// "…to sidebar" / "…from sidebar" are the directory's own labels — the home page's
// Apps map carries a plain "Pin Agents", so the short form is ambiguous.
await expect(page.getByRole('button', { name: /Agents (to|from) sidebar/ })).toBeVisible({ timeout: 15_000 })
}
// The snapshot the token carries is what the sidebar starts from.
await openDirectory()
await page.getByRole('button', { name: 'Pin Agents to sidebar' }).click()
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible()
// Only a write the SERVER acknowledged earns the stamp that out-ranks the snapshot.
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo.console2.prefs.z.writtenAt')), { timeout: 10_000 })
.not.toBeNull()
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
// Still pinned — the hour-old snapshot did not win. Asserted on what the user sees…
await openDirectory()
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible({ timeout: 15_000 })
// …and on what was actually kept (models from the snapshot, agents from the write).
const pins = await page.evaluate(() => {
const raw = JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}')
return (raw.pins ?? []).map((p: { id: string }) => p.id)
})
expect(pins).toContain('agents')
expect(pins).toContain('models')
await ctx.close()
})
+10 -9
View File
@@ -1,12 +1,12 @@
/**
* e2e: two-tenant BILLING ISOLATION through the `/billing/*` proxy.
* e2e: two-tenant BILLING ISOLATION through the `/v1/billing/*` proxy.
*
* The proxy (app/billing/v1/[...path]/route.ts) resolves the billing subject from the
* The proxy (app/v1/billing/[...path]/route.ts) resolves the billing subject from the
* session server-side and pins the full subject-key set (user/userId/customerId) +
* the X-Org-Id header, so a tenant can only ever read its OWN commerce ledger. This
* spec proves that end-to-end against the LIVE proxy: two accounts in DIFFERENT orgs
* each fetch `/billing/subscriptions` (and `/payment-methods`), and we assert the
* two result sets are disjoint — neither tenant can see the other's rows.
* each fetch `/v1/billing/subscriptions` (and `/v1/billing/methods`), and we assert
* the two result sets are disjoint — neither tenant can see the other's rows.
*
* This is the regression guard for the IDOR RED found (the proxy previously pinned
* only `?user=` while commerce filters subscriptions on `?userId=`, so subscriptions
@@ -36,11 +36,12 @@ async function signIn(page: Page, email: string, password: string) {
await page.waitForLoadState('domcontentloaded')
}
/** Fetch a billing path through the same-origin DATA proxy (`/billing/v1/*`), as the
* signed-in browser. (`/billing/<slug>` without `v1/` is a UI tab, served by the SPA.) */
/** Fetch a billing path through the same-origin DATA proxy (`/v1/billing/*`), as the
* signed-in browser. (`/billing/<slug>` is a UI tab, served by the SPA — it differs at
* the FIRST path segment, so the two never collide.) */
async function billing(page: Page, path: string): Promise<{ status: number; ids: string[] }> {
return page.evaluate(async (p) => {
const res = await fetch(`/billing/v1/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
const res = await fetch(`/v1/billing/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
let ids: string[] = []
try {
const body = await res.json()
@@ -73,9 +74,9 @@ test.describe('billing is isolated per tenant through the proxy', () => {
await signIn(pageB, B.email, B.password)
// `invoices` is included because its row ids drive the per-invoice PDF URL
// (`/billing/v1/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
// (`/v1/billing/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
// proves a user can only ever build a PDF URL for their OWN org's invoices.
for (const path of ['subscriptions', 'payment-methods', 'invoices']) {
for (const path of ['subscriptions', 'methods', 'invoices']) {
const a = await billing(pageA, path)
const b = await billing(pageB, path)
+6 -6
View File
@@ -120,7 +120,7 @@ async function auditSurface(page: Page, slug: string, name: string, marker: RegE
// Honest states that count as a truthful render for ANY surface (real content is added
// per-surface). Kept in ONE place so every marker is consistent.
const HONEST =
'Add credits|Your session expired|Access required|Not enabled|Not available on this deployment|initializing|runtime|managed by Hanzo|Connected|Operator access|No .* yet|not connected|not configured|Sign in'
'Add credits|Your session expired|Access required|Not enabled|Not available on this deployment|initializing|runtime|managed by Hanzo|Connected|SuperAdmin access|No .* yet|not connected|not configured|Sign in'
// ════════════════════════════════════════════════════════════════════════════════
// A. UNAUTHENTICATED fail-closed proof — ALWAYS runs (no credentials required).
@@ -131,8 +131,8 @@ test.describe('Money/usage/o11y surface is fail-closed for anonymous (unauthenti
'/v1/billing/balance',
'/v1/billing/invoices',
'/v1/billing/usage',
'/v1/billing/payment-methods',
'/v1/billing/spend-alerts',
'/v1/billing/methods',
'/v1/billing/alerts',
'/v1/usage/summary',
'/v1/get-cloud-usages',
'/v1/o11y/observations',
@@ -314,9 +314,9 @@ test.describe.serial('Billing / Settings / Usage / o11y render smoke (authentica
test('Alerts renders (alerting rules or honest state)', async () => {
await auditSurface(page, 'alerts', 'Alerts', new RegExp(`Alert|rule|notification|${HONEST}`, 'i'))
})
test('Fleet Observability renders (global-admin board or honest operator-access state)', async () => {
// For a non-global-admin this is honestly `OperatorAccessRequired` — that IS a pass.
await auditSurface(page, 'fleet-o11y', 'Fleet Observability', new RegExp(`Fleet Observability|Requests|Tokens|Latency|Top organizations|Operator access|${HONEST}`, 'i'))
test('Fleet Observability renders (global-admin board or honest superadmin-access state)', async () => {
// For a non-global-admin this is honestly `SuperAdminRequired` — that IS a pass.
await auditSurface(page, 'fleet-o11y', 'Fleet Observability', new RegExp(`Fleet Observability|Requests|Tokens|Latency|Top organizations|SuperAdmin access|${HONEST}`, 'i'))
})
// ── AGGREGATE — the dead-card audit. FLAGS every surface that showed a dead
+1 -1
View File
@@ -40,7 +40,7 @@ const CANONICAL_IDS: string[] = JSON.parse(readFileSync(join(process.cwd(), 'e2e
* are NOT registry ids — they must resolve via SLUG_ALIASES to a real module (never
* a 404 blank). Auditing them here proves the alias map end-to-end against the real app.
*/
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search', 'mlpipelines', 'kubeflow']
const ALIAS_SLUGS = ['traces', 'deploy', 'plans-pricing', 'wallets', 'model-catalog', 'fine-tuning', 'web-search']
const IDS: string[] = [...CANONICAL_IDS, ...ALIAS_SLUGS]
/** A global-admin (sees every surface) or a tenant customer (Dave/maxpower shape). */
+4 -4
View File
@@ -3,7 +3,7 @@
*
* Runs against a LOCAL server (BASE_URL=http://localhost:4000) with the whole network
* mocked (same pattern as blank-audit): `/auth/session` → a global admin so the shell
* mounts, `/v1/billing/spend-alerts` → real-shaped budget rows (org default + project
* mounts, `/v1/billing/alerts` → real-shaped budget rows (org default + project
* warn + service over + unlimited/rate-limit-only), everything else → an empty-ok
* envelope.
*
@@ -39,7 +39,7 @@ const ACCOUNT = {
signupApplication: 'hanzo-cloud',
}
/** Real-shaped `/v1/billing/spend-alerts` rows — one per verdict/scope (threshold = cents). */
/** Real-shaped `/v1/billing/alerts` rows — one per verdict/scope (threshold = cents). */
const BUDGETS = [
{ id: 'b1', title: 'Org monthly cap', threshold: 500000, currency: 'usd', project: '', service: '', enforce: true, softPct: 80, rateLimitRpm: 0, periodSpentCents: 312000, over: false, warn: false },
{ id: 'b2', title: 'Inference budget', threshold: 200000, currency: 'usd', project: 'acme-prod', service: 'inference', enforce: false, softPct: 75, rateLimitRpm: 600, periodSpentCents: 186000, over: false, warn: true },
@@ -61,8 +61,8 @@ async function mock(route: Route) {
if (path.startsWith('/auth/')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
}
// The page under test — the real spend-alerts contract.
if (path === '/v1/billing/spend-alerts') {
// The page under test — the real alerts contract.
if (path === '/v1/billing/alerts') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(BUDGETS) })
}
+32 -20
View File
@@ -1,11 +1,12 @@
/**
* e2e: brand-forward chrome + voice — mocked-network render proof.
*
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant now opens
* from the TOPBAR (a small brand-H "Chat with Hanzo" + a "Talk to Hanzo" mic), the
* top-left SidebarBrand renders the org's own logo (white-label), and the Developers
* dock is drag-resizable with a live "Create key". This spec proves all of it in a
* browser.
* The Chrome wave: the big floating chat CIRCLE was removed; the assistant opens from
* ONE control — a floating bottom-right cluster (a brand-H "Ask Hanzo" + a "Talk to
* Hanzo" mic, `AssistantFab`), NOT the topbar, which carries navigation and account
* chrome only. The top-left SidebarBrand renders the org's own logo (white-label), and
* the Developers dock is drag-resizable with a live "Create key". This spec proves all
* of it in a browser.
*
* Same harness as workbench.spec (the closest sibling): a LOCAL server with the
* network mocked. `primeSession` seeds the IAM-PKCE identity AND the first-run gates
@@ -13,7 +14,7 @@
* real-shaped ledger rows for the dock's Overview, `/v1/models` → a small catalog for
* the assistant's model list; everything else → an empty-ok envelope.
*
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the topbar mic
* Voice gotcha: headless chromium ships NO webkitSpeechRecognition, so the mic
* (rendered only when `voiceSupported()`) would be absent for an environment reason,
* not a code one. A tiny, inert Web Speech stub is injected BEFORE load
* (`installVoiceStub`) so `voiceSupported()` is deterministically true and the mic
@@ -107,20 +108,20 @@ function installVoiceStub(page: Page) {
})
}
/** Prime + navigate; the topbar brand-H is on EVERY viewport, so it is the mount signal. */
/** Prime + navigate; the floating brand-H is on EVERY viewport, so it is the mount signal. */
async function openHome(page: Page, waitForMount = true) {
await installVoiceStub(page)
await page.route('**/*', mock)
await primeSession(page)
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
if (waitForMount) {
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
await expect(page.locator('[aria-label="Ask Hanzo"]').first()).toBeVisible({ timeout: 20_000 })
}
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('the floating circle is gone; the topbar carries chat + voice, the sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
test('one floating control carries chat + voice; the topbar carries neither; sidebar brand + docked assistant + Developers dock work', async ({ browser }) => {
// laptop (≥ lg 1024): the persistent sidebar, the Developers dock, and the docked
// assistant column are all present (they are desktop-only concerns).
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
@@ -130,10 +131,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
// 1. The OLD floating circle is GONE — the bubble that covered page content.
await expect(page.locator('[aria-label="Open AI assistant"]')).toHaveCount(0)
// 2. Topbar: the small brand-H "Chat with Hanzo" AND the "Talk to Hanzo" mic, both
// visible (the mic renders because the Web Speech stub makes voiceSupported() true).
await expect(page.locator('[aria-label="Chat with Hanzo"]').first()).toBeVisible()
await expect(page.locator('[aria-label="Talk to Hanzo"]').first()).toBeVisible()
// 2. ONE floating control, bottom-right: the brand-H "Ask Hanzo" AND the "Talk to
// Hanzo" mic (the mic renders because the Web Speech stub makes voiceSupported()
// true) — and the topbar carries no assistant control at all. The two used to live
// up there beside the search box, which put the assistant in a third place.
// Scoped to the control itself: the assistant's own composer carries a mic with
// the same label, mounted-but-hidden until the panel opens, so a bare
// `[aria-label="Talk to Hanzo"]` matches that one first and reads "hidden".
const fab = page.getByTestId('assistant-fab')
await expect(fab.locator('[aria-label="Ask Hanzo"]')).toBeVisible()
await expect(fab.locator('[aria-label="Talk to Hanzo"]')).toBeVisible()
await expect(page.locator('.hz-topbar [aria-label="Ask Hanzo"]')).toHaveCount(0)
await expect(page.locator('.hz-topbar [aria-label="Talk to Hanzo"]')).toHaveCount(0)
// 3. The top-left SidebarBrand renders the org logo / BrandMark (an <img> or <svg>).
const brand = page.locator('[aria-label*="right-click for brand menu"]').first()
@@ -147,15 +156,18 @@ test('the floating circle is gone; the topbar carries chat + voice, the sidebar
await expect(page.locator('[title="Drag to resize"]').first()).toBeVisible()
await expect(page.locator('text=Create key').first()).toBeVisible({ timeout: 15_000 })
// 5. Clicking "Chat with Hanzo" opens the DOCKED assistant surface — the "Assistant"
// header + its Undock control appear (uniquely the docked panel at lg+).
await page.locator('[aria-label="Chat with Hanzo"]').first().click()
// 5. Clicking "Ask Hanzo" opens the DOCKED assistant surface — the "Assistant"
// header + its Undock control appear (uniquely the docked panel at lg+). The
// floating control then steps aside: at lg+ the docked column IS the assistant,
// so keeping a button to open it on top of itself would be a second way in.
await fab.locator('[aria-label="Ask Hanzo"]').click()
await expect(page.locator('[aria-label^="Undock"]').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('Assistant', { exact: true }).filter({ visible: true }).first()).toBeVisible()
await expect(page.locator('[aria-label="Ask Hanzo"]')).toHaveCount(0)
// 6. The mic is wired: "Talk to Hanzo" → startVoice → the conversation opens the
// recognition (voiceSignal effect → voice.start() → the stub records the call).
await page.locator('[aria-label="Talk to Hanzo"]').first().click()
// 6. The mic is wired: "Talk to Hanzo" (now the open conversation's own) → the
// recognition opens (voice.start() → the stub records the call).
await page.locator('[aria-label="Talk to Hanzo"]').filter({ visible: true }).first().click()
await expect
.poll(() => page.evaluate(() => (window as unknown as { __voiceStarted?: number }).__voiceStarted ?? 0), { timeout: 15_000 })
.toBeGreaterThan(0)
@@ -179,7 +191,7 @@ test('renders across breakpoints with no horizontal body scroll on a phone; scre
// (real render, or an honest blank shell if the sandbox can't paint the SPA).
await openHome(page, false)
await page
.locator('[aria-label="Chat with Hanzo"]')
.locator('[aria-label="Ask Hanzo"]')
.first()
.waitFor({ state: 'visible', timeout: 20_000 })
.catch(() => {})
+10 -10
View File
@@ -14,7 +14,7 @@
*
* Run:
* HANZO_PASSWORD=xxx pnpm e2e
* HANZO_PASSWORD=xxx HANZO_API_KEY=hk-xxx pnpm e2e
* HANZO_PASSWORD=xxx HANZO_API_KEY=sk-xxx pnpm e2e
*/
import { test, expect, type Page } from '@playwright/test'
@@ -151,15 +151,15 @@ test.describe('Hanzo Cloud Console e2e', () => {
if (needsCreate) {
await createBtn.click()
// One-time reveal card with the hk- key
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
// One-time reveal card with the sk- key
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
await expect(page.locator('text=/shown only once/i')).toBeVisible()
await expect(page.locator('button:has-text("Copy")')).toBeVisible()
console.log('✓ API key created (hk- one-time reveal shown)')
console.log('✓ API key created (sk- one-time reveal shown)')
} else {
// Key already exists
await expect(hasKey).toBeVisible({ timeout: 10_000 })
await expect(page.locator('text=/hk-…|hk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('text=/sk-…|sk-[A-Za-z0-9]{3,}/i')).toBeVisible({ timeout: 5_000 })
console.log('✓ API key already exists (prefix shown)')
}
})
@@ -183,19 +183,19 @@ test.describe('Hanzo Cloud Console e2e', () => {
} else if (await createBtn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await createBtn.click()
}
await expect(page.locator('text=/hk-/')).toBeVisible({ timeout: 25_000 })
await expect(page.locator('text=/sk-/')).toBeVisible({ timeout: 25_000 })
// Grab the FULL key from the one-time reveal — never the masked display
// (the account card shows `hk-2f18…` with an ellipsis, which is not a
// usable credential). Match only a full hk- token (no `…`/`...`).
const fullKey = /hk-[A-Za-z0-9._-]{16,}/
// (the account card shows `sk-2f18…` with an ellipsis, which is not a
// usable credential). Match only a full sk- token (no `…`/`...`).
const fullKey = /sk-[A-Za-z0-9._-]{16,}/
const keyEl = page.locator('[style*="monospace"]').filter({ hasText: fullKey }).first()
apiKey = (((await keyEl.textContent().catch(() => '')) ?? '').match(fullKey) ?? [''])[0]
if (!apiKey) {
const m = ((await page.textContent('body')) ?? '').match(fullKey)
apiKey = m ? m[0] : ''
}
expect(apiKey, 'Could not extract hk- key from page').toMatch(/^hk-/)
expect(apiKey, 'Could not extract sk- key from page').toMatch(/^sk-/)
console.log(`✓ Extracted key prefix: ${apiKey.slice(0, 11)}`)
}
+311
View File
@@ -0,0 +1,311 @@
/**
* The design gate — the invariants of the shell, asserted on COMPUTED STYLE and
* GEOMETRY in a real browser.
*
* This spec is the deliverable, not the screenshots. Every rule below was a real
* defect measured on the running console, and a rule that only lives in a review
* comes back. A status code proves a server answered; this proves a human can
* read the page.
*
* WHAT IT PINS
* 1. No all-caps, anywhere — neither `text-transform: uppercase` nor a string
* TYPED in caps. This is the hard rule and the whole reason the file exists.
* 2. ONE type scale, ONE radius scale, ONE spacing ramp — asserted as
* membership, so a new value cannot be introduced without deciding to.
* 3. Every stacking layer resolves to the ladder in app/design/z.css, never a
* literal. The console had drifted to 9999 / 100000 / 100001 / 100002.
* 4. The overlays actually paint: opaque background, on-screen box. Two of
* tonight's bugs were a control that rendered identically in both states and
* a footer that ate clicks while returning 200.
* 5. Contrast is computed from the colours that actually painted.
* 6. The body never scrolls sideways, at 1440 or at 390.
*
* KNOWN EXEMPTIONS, each deliberate and narrow:
* - Acronyms (`API`, `GPU`, `CIDR`, …) are not shouting; the allow-list is
* explicit so a new one is a decision, not an accident.
* - An avatar/brand MONOGRAM scales with its circle — it is a graphic, not app
* text — so text-size membership skips it. Marking it takes BOTH a
* `[data-monogram]` ancestor AND text of at most three characters, so a marker
* placed around a whole distributed component (the only place it CAN go, since
* @hanzo/ui paints the org mark itself) still cannot exempt that component's
* labels — only its glyph.
* - Tamagui's `circular` variant compiles to a 100000px radius; that is the
* same concept as our pill token, so both count as "pill".
* - Next's dev overlay injects its own chrome; specs run against the app root.
* - An `aria-hidden` subtree is not content. A CLOSED drawer parks off screen by
* design — the nav drawer at x = -320, the account drawer at x = 390 — which is
* how a slide-over animates, not a clip.
* - The ladder governs where OUR chrome sits. A library ordering its own
* internals is its business: @hanzo/gui's Dialog puts its overlay at 1 and its
* content at 2 INSIDE its portal, so the rule applies above 10. And the Gui
* portal HOST itself is pinned to a hardcoded 105001 that no console config can
* reach — REPORTED as a library finding, excluded here by its own class marker
* rather than by raising the ceiling and quietly letting our literals back in.
*/
import { test, expect, type Page } from '@playwright/test'
import { primeSession } from './_session'
/** Genuine acronyms — capitalised because that is how they are spelled. */
const ACRONYMS = new Set([
'AI', 'ML', 'LLM', 'API', 'SDK', 'IDE', 'CLI', 'CDN', 'DNS', 'DNSSEC', 'TTL', 'CIDR', 'VPC',
'IAM', 'KMS', 'HSM', 'MPC', 'SSO', 'MFA', 'TOTP', 'OIDC', 'PKCE', 'JWT', 'CSRF', 'SAFE',
'CPU', 'GPU', 'GPUS', 'RAM', 'SSD', 'VRAM', 'PVC', 'S3', 'KV', 'SQL', 'URL', 'URI', 'JSON',
'HTTP', 'HTTPS', 'POST', 'CNAME', 'AAAA', 'P95', 'P99', 'MRR', 'SKU', 'OSS', 'CRM', 'ERP',
'CMS', 'RAG', 'OTEL', 'OTLP', 'RED', 'ZIP', 'PDF', 'CSV', 'ID', 'IDS', 'UI', 'UX', 'WCAG',
])
/** The ONE type scale (gui.config.ts FONT_SIZE + app/design/typography.css). */
const TYPE = new Set([11, 13, 14, 15, 17, 21, 26, 32, 40, 48])
/** The ONE radius scale: control · input/row · panel · pill. */
const RADIUS = new Set([0, 6, 8, 12, 9999, 100000])
/** The ONE spacing ramp (gui.config.ts STEP). */
const SPACE = new Set([0, 1, 2, 3, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208])
/** The ladder in app/design/z.css — the only stacking values that may paint. */
const Z = new Set([10, 200, 300, 400, 500, 600, 700, 800])
type Audit = {
capsComputed: string[]
capsTyped: string[]
offType: { size: number; text: string }[]
offRadius: { radius: number; cls: string }[]
offSpace: { pad: number; cls: string }[]
offZ: { z: number; cls: string }[]
lowContrast: { ratio: number; text: string; fg: string; bg: string }[]
hScroll: boolean
bodyBg: string
}
/** Runs entirely in the page: reads what PAINTED, never what the source says. */
async function audit(page: Page, acronyms: string[]): Promise<Audit> {
return page.evaluate((acr) => {
const ACR = new Set(acr)
const out: Audit = {
capsComputed: [], capsTyped: [], offType: [], offRadius: [], offSpace: [],
offZ: [], lowContrast: [], hScroll: false, bodyBg: '',
}
const TYPE = new Set([11, 13, 14, 15, 17, 21, 26, 32, 40, 48])
const RADIUS = new Set([0, 6, 8, 12, 9999, 100000])
const SPACE = new Set([0, 1, 2, 3, 4, 6, 8, 10, 12, 14, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208])
const Z = new Set([10, 200, 300, 400, 500, 600, 700, 800])
const cls = (el: Element) => (typeof el.className === 'string' ? el.className.slice(0, 90) : el.tagName)
const px = (v: string) => Math.round(parseFloat(v) || 0)
const rendered = (el: Element) => !!(el as HTMLElement).offsetParent || el === document.body
// sRGB relative luminance → WCAG contrast ratio.
const lum = (c: string) => {
const m = c.match(/[\d.]+/g)
if (!m || m.length < 3) return null
if (m.length > 3 && parseFloat(m[3]) === 0) return null // fully transparent
const [r, g, b] = m.slice(0, 3).map((n) => {
const s = parseFloat(n) / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/** The nearest ancestor that actually paints a background. */
const bgOf = (el: Element): string => {
for (let e: Element | null = el; e; e = e.parentElement) {
const b = getComputedStyle(e).backgroundColor
if (b && !/rgba\(0, 0, 0, 0\)|transparent/.test(b)) return b
}
return getComputedStyle(document.body).backgroundColor
}
for (const el of Array.from(document.querySelectorAll('body *'))) {
if (el.closest('nextjs-portal, [data-nextjs-toast], [aria-hidden="true"]')) continue
const cs = getComputedStyle(el)
if (cs.display === 'none' || cs.visibility === 'hidden') continue
const leaf = el.children.length === 0
const text = (el.textContent || '').trim()
// 1 · caps
if (cs.textTransform === 'uppercase' && leaf && text) out.capsComputed.push(text.slice(0, 48))
// 2 · scales — only on nodes that actually paint
if (rendered(el)) {
const isMonogram = text.length <= 3 && !!el.closest('[data-monogram]')
if (leaf && text && !isMonogram && !el.closest('svg')) {
const s = px(cs.fontSize)
if (!TYPE.has(s)) out.offType.push({ size: s, text: text.slice(0, 40) })
// 5 · contrast, on the colours that painted
const f = lum(cs.color)
const b = lum(bgOf(el))
if (f !== null && b !== null) {
const ratio = (Math.max(f, b) + 0.05) / (Math.min(f, b) + 0.05)
const large = s >= 21 || (s >= 17 && Number(cs.fontWeight) >= 700)
if (ratio < (large ? 3 : 4.5)) {
out.lowContrast.push({ ratio: Math.round(ratio * 100) / 100, text: text.slice(0, 32), fg: cs.color, bg: bgOf(el) })
}
}
}
const r = px(cs.borderTopLeftRadius)
if (r && !RADIUS.has(r)) out.offRadius.push({ radius: r, cls: cls(el) })
for (const p of [cs.paddingLeft, cs.paddingTop]) {
const v = px(p)
if (v && !SPACE.has(v)) out.offSpace.push({ pad: v, cls: cls(el) })
}
}
// 3 · stacking — only where a layer actually paints, only above a library's
// own local ordering, and never the Gui portal host (see the header).
const guiPortalHost = typeof el.className === 'string' && el.className.includes('_dsp_contents')
if (cs.zIndex !== 'auto' && !guiPortalHost) {
const z = Number(cs.zIndex)
if (z > 10 && !Z.has(z)) out.offZ.push({ z, cls: cls(el) })
}
}
// 1b · caps TYPED into a string
const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
let n: Node | null
while ((n = w.nextNode())) {
if ((n.parentElement as HTMLElement | null)?.closest('nextjs-portal, [data-monogram]')) continue
const s = (n.nodeValue || '').trim()
if (s.length < 4 || !/^[A-Z][A-Z0-9 &/·—-]+$/.test(s) || !/[A-Z]{4,}/.test(s)) continue
if (!s.split(/[^A-Z0-9]+/).every((p) => !p || ACR.has(p))) out.capsTyped.push(s.slice(0, 48))
}
out.hScroll = document.documentElement.scrollWidth > document.documentElement.clientWidth
out.bodyBg = getComputedStyle(document.body).backgroundColor
return out
}, acronyms)
}
/** Land on a dashboard route with a primed session and let the SPA settle. */
async function open(page: Page, path: string): Promise<void> {
await page.route('**/v1/**', (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: '{"data":[],"items":[],"status":"ok"}' }))
await primeSession(page)
await page.goto(path, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('nav[aria-label="Products"]', { timeout: 45_000 }).catch(() => {})
await page.waitForTimeout(6000)
}
const dedupe = <T,>(xs: T[]): T[] => Array.from(new Set(xs.map((x) => JSON.stringify(x)))).map((s) => JSON.parse(s) as T)
// The shell's real states. Level 1 is the rail + home; drilled is the second-level
// nav; settings is the panel-of-rows surface; the palette is the top overlay.
const STATES: [name: string, path: string][] = [
['level 1', '/'],
['drilled', '/agents'],
['settings panels', '/agents/settings'],
]
for (const [name, path] of STATES) {
test(`${name} — no caps, one scale, one ladder`, async ({ page }) => {
await open(page, path)
const a = await audit(page, [...ACRONYMS])
// THE HARD RULE. No exceptions, no text-transform, no typed caps.
expect(dedupe(a.capsComputed), 'text-transform: uppercase').toEqual([])
expect(dedupe(a.capsTyped), 'strings typed in caps').toEqual([])
// ONE of each scale.
expect(dedupe(a.offType), 'font-size off the type scale').toEqual([])
expect(dedupe(a.offRadius), 'border-radius off the radius scale').toEqual([])
expect(dedupe(a.offSpace), 'padding off the 4px ramp').toEqual([])
expect(dedupe(a.offZ), 'z-index not from the --z-* ladder').toEqual([])
// Readable, on the black canvas the brief asks for.
expect(a.bodyBg).toBe('rgb(0, 0, 0)')
expect(dedupe(a.lowContrast), 'text below WCAG AA against its painted background').toEqual([])
expect(a.hScroll, 'the body must never scroll sideways').toBe(false)
})
}
test('the command palette paints, is on screen, and shouts at nobody', async ({ page }) => {
await open(page, '/')
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k')
await page.waitForTimeout(1200)
// It must actually PAINT — a transparent, unstacked overlay is the library
// failure mode this repo has already been bitten by twice.
const box = await page.evaluate(() => {
// Several dialogs live in the DOM at rest — the nav drawer is parked OFF
// screen at x = -320 — so pick the one that is actually on screen.
const el = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"]')).find((d) => {
const r = d.getBoundingClientRect()
return r.width > 240 && r.height > 40 && r.left >= 0 && r.right <= innerWidth + 1 &&
getComputedStyle(d).visibility !== 'hidden' && getComputedStyle(d).display !== 'none'
}) ?? null
if (!el) return null
const cs = getComputedStyle(el)
const r = el.getBoundingClientRect()
const bg = (() => {
for (let e: Element | null = el; e; e = e.parentElement) {
const b = getComputedStyle(e).backgroundColor
if (b && !/rgba\(0, 0, 0, 0\)|transparent/.test(b)) return b
}
return 'rgba(0, 0, 0, 0)'
})()
return { bg, z: cs.zIndex, x: r.x, y: r.y, w: r.width, h: r.height, vw: innerWidth, vh: innerHeight }
})
expect(box, 'the palette did not open').not.toBeNull()
expect(box!.bg, 'the palette rendered transparent').not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
expect(box!.w).toBeGreaterThan(240)
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.y).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.w).toBeLessThanOrEqual(box!.vw + 1)
const a = await audit(page, [...ACRONYMS])
expect(dedupe(a.capsComputed)).toEqual([])
expect(dedupe(a.capsTyped)).toEqual([])
expect(dedupe(a.offZ), 'the palette must sit on the ladder').toEqual([])
})
test('the rail is keyboard-reachable and a collapsed section is out of the tab order', async ({ page }) => {
await open(page, '/')
const reach = await page.evaluate(() => {
const nav = document.querySelector('nav[aria-label="Products"]') as HTMLElement | null
if (!nav) return null
const focusable = Array.from(nav.querySelectorAll<HTMLElement>('button, a[href], [tabindex]:not([tabindex="-1"])'))
.filter((el) => el.offsetParent !== null)
// Rows inside a collapsed accordion are `inert` — present, but not tabbable.
const inertRows = Array.from(nav.querySelectorAll('.hz-acc[data-open="false"] button')).length
const inertTabbable = Array.from(nav.querySelectorAll<HTMLElement>('.hz-acc[data-open="false"] button'))
.filter((el) => el.offsetParent !== null && !el.closest('[inert]')).length
// Every reachable row must be inside the viewport — a control tab lands on
// but cannot be seen is the same defect as one that cannot be reached.
const offscreen = focusable.filter((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && (r.right < 0 || r.left > innerWidth || r.bottom < 0)
}).length
return { count: focusable.length, offscreen, inertRows, inertTabbable }
})
expect(reach, 'no rail found').not.toBeNull()
expect(reach!.count, 'the rail has no keyboard-reachable rows').toBeGreaterThan(3)
expect(reach!.offscreen, 'a rail row is focusable but painted off screen').toBe(0)
expect(reach!.inertTabbable, 'a collapsed section leaked rows into the tab order').toBe(0)
})
test('nothing scrolls sideways on a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await open(page, '/')
const a = await audit(page, [...ACRONYMS])
expect(a.hScroll).toBe(false)
expect(dedupe(a.capsComputed)).toEqual([])
expect(dedupe(a.capsTyped)).toEqual([])
// Painted past the right edge is only a defect when nothing can scroll to it.
// Wide content (a DataTable, a code block) is REQUIRED to scroll inside its own
// container, and DataTable already does — that is correct, not a clip.
const overflow = await page.evaluate(() => {
const scrollable = (el: Element) => {
for (let e: Element | null = el.parentElement; e; e = e.parentElement) {
const ox = getComputedStyle(e).overflowX
if (ox === 'auto' || ox === 'scroll') return true
}
return false
}
return Array.from(document.querySelectorAll('body *'))
.filter((el) => {
if (el.closest('[aria-hidden="true"]')) return false
const r = el.getBoundingClientRect()
return r.width > 0 && r.right > innerWidth + 1 && !scrollable(el)
})
.slice(0, 6)
.map((el) => (el.textContent || el.tagName).trim().slice(0, 44))
})
expect(overflow, 'clipped past the right edge with nothing to scroll it').toEqual([])
})
+397
View File
@@ -0,0 +1,397 @@
/**
* e2e: find and do — pin, sort, filter, search, and the keyboard that drives them.
*
* Every claim here is measured in a real browser on COMPUTED STYLE and GEOMETRY,
* because the failures this lane exists to prevent are invisible to a status code:
* a pin that reports success and is gone after a reload, an affordance that renders
* at zero opacity forever, a control painted off its own row.
*
* Local fixture server + mocked network; `primeSession` supplies the IAM-PKCE
* identity. The preference PATCH is mocked to echo nothing, which is the HONEST
* worst case — it is exactly the condition (an account that never reports the key
* back) under which pins used to be lost.
*
* Run: BASE_URL=http://localhost:4300 npx playwright test find-and-do
*/
import { test, expect, type Route, type Page } from '@playwright/test'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4300'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
/** A small real-shaped model catalog, enough for the Models + Marketplace lists. */
const MODELS = {
object: 'list',
data: [
{ id: 'zen5', owned_by: 'hanzo' },
{ id: 'zen5-mini', owned_by: 'hanzo' },
{ id: 'anthropic/claude-opus-4.6', owned_by: 'anthropic' },
{ id: 'qwen3.5-397b', owned_by: 'hanzo' },
],
}
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (url.pathname === '/v1/models') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MODELS) })
}
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
})
}
/**
* Sign in and land on `path`. `ready` is what proves the signed-in shell mounted —
* it defaults to the rail's own pin affordances, which exist only on the DESKTOP
* rail (below lg the nav is a drawer), so a mobile viewport passes its own signal.
*/
async function boot(page: Page, path = '/', ready?: () => Promise<void>) {
await page.route('**/*', mock)
await primeSession(page)
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
if (ready) return ready()
await expect(page.getByRole('button', { name: /^(Pin|Unpin) / }).first()).toBeVisible({ timeout: 30_000 })
}
/**
* The palette, opened by the real ⌘K path rather than by clicking chrome.
*
* ⌘K is a TOGGLE bound on `window`, so a press that lands mid-navigation (before the
* destination route has mounted its listener) is simply lost. Retrying the real
* gesture is honest — it still proves the shortcut works — where a single press would
* only prove the test's timing.
*/
/** The one mounted palette dialog (the one that owns the search input). */
const palette = (page: Page) =>
page
.locator('[role="dialog"]')
.filter({ has: page.getByPlaceholder('Search apps and commands…') })
.last()
async function openPalette(page: Page) {
const input = page.getByPlaceholder('Search apps and commands…')
await expect(async () => {
await page.keyboard.press('ControlOrMeta+k')
await expect(input).toBeVisible({ timeout: 2_000 })
}).toPass({ timeout: 20_000 })
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('a pin survives a reload — the account is silent, the cache is not', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
const pin = page.getByRole('button', { name: 'Pin Agents' }).first()
await expect(pin).toBeVisible()
await pin.click()
// It reads as pinned immediately…
await expect(page.getByRole('button', { name: 'Unpin Agents' }).first()).toBeVisible()
// …and is STILL pinned after a full reload. Before this lane's fix the account's
// (silent) view overwrote the cache here and the pin was gone.
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Unpin Agents' }).first()).toBeVisible({ timeout: 30_000 })
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}'),
)
expect(stored.pins.map((p: { id: string }) => p.id)).toContain('agents')
// Unpinning is just as durable, so the state is genuinely the user's, not sticky.
await page.getByRole('button', { name: 'Unpin Agents' }).first().click()
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: 'Pin Agents' }).first()).toBeVisible({ timeout: 30_000 })
await ctx.close()
})
test('typing a product name opens that product — pins never outrank what you typed', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
// Models and Chat are pinned by default. Before this was split, `pinnedFirst` was
// applied to the RANKED list too, so a barely-matching pinned product outranked an
// exact name match and "billing" + ↵ opened /models. Enter is the honest probe:
// it asserts on where the user actually lands, not on DOM order.
for (const [query, path] of [
['agents', '/agents'],
['billing', '/billing'],
['vector', '/vector'],
]) {
await openPalette(page)
await page.getByPlaceholder('Search apps and commands…').fill(query)
await expect(page.locator('#cmdk-active').first()).toBeVisible()
await page.keyboard.press('Enter')
await expect
.poll(() => new URL(page.url()).pathname, { timeout: 15_000 })
.toBe(path)
}
await ctx.close()
})
test('the default view leads with pins, and a result can be pinned without leaving', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await openPalette(page)
// A product query (not one that also matches a verb like "ask"/"apps"), so the
// selection lands on a destination — actions rank first and carry no pin.
await page.getByPlaceholder('Search apps and commands…').fill('agents')
await expect(page.locator('#cmdk-active')).toBeVisible()
// The pin on the SELECTED result: a real control, at the row's RIGHT edge.
const activeRow = page.locator('#cmdk-active')
const pinBtn = activeRow.getByRole('button', { name: /^(Pin|Unpin) / })
await expect(pinBtn).toHaveCount(1)
const rowBox = await activeRow.boundingBox()
const pinBox = await pinBtn.boundingBox()
expect(rowBox).not.toBeNull()
expect(pinBox).not.toBeNull()
// Right edge: the pin sits in the last quarter of its row, and inside it.
expect(pinBox!.x).toBeGreaterThan(rowBox!.x + rowBox!.width * 0.75)
expect(pinBox!.x + pinBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width + 1)
// Vertically centred on its own row, not floating above or below it.
const rowMid = rowBox!.y + rowBox!.height / 2
const pinMid = pinBox!.y + pinBox!.height / 2
expect(Math.abs(rowMid - pinMid)).toBeLessThan(4)
// A real hit target, not a 2px sliver.
expect(pinBox!.width).toBeGreaterThanOrEqual(20)
expect(pinBox!.height).toBeGreaterThanOrEqual(20)
const label = (await pinBtn.getAttribute('aria-label')) ?? ''
const product = label.replace(/^(Pin|Unpin) /, '')
// ⌥↵ pins the selection and KEEPS the palette open — curating is repeatable.
await page.keyboard.press('Alt+Enter')
await expect(page.getByPlaceholder('Search apps and commands…')).toBeVisible()
await expect(activeRow.getByRole('button', { name: `Unpin ${product}` })).toHaveCount(1)
await expect(activeRow.getByRole('button', { name: /^(Pin|Unpin) / })).toHaveAttribute('aria-pressed', 'true')
await page.screenshot({ path: join(SHOTS, 'palette-pin.png') })
// Clear the query: the default view collects the pins into one leading section,
// under the same word the sidebar uses.
await page.getByPlaceholder('Search apps and commands…').fill('')
// Scoped to the PALETTE. The sidebar has its own "Pinned" heading, so an unscoped
// text match here would pass whether or not the palette groups anything at all —
// and an assertion that can pass for the wrong reason is worse than no assertion.
//
// Read via textContent, not a text locator: the section labels are uppercased in
// CSS, so `getByText('Pinned')` matches the rendered "PINNED" inconsistently.
const scan = await page.evaluate(() => {
const inputs = Array.from(document.querySelectorAll('input')).filter((i) =>
(i.getAttribute('placeholder') ?? '').startsWith('Search apps'),
)
const host = inputs[0]?.closest('[role="dialog"]')
if (!host) return null
const text = Array.from(host.querySelectorAll('*'))
.filter((e) => e.children.length === 0)
.map((e) => (e.textContent ?? '').trim())
.filter(Boolean)
return { palettes: inputs.length, hasPinnedSection: text.includes('Pinned') }
})
expect(scan).not.toBeNull()
// Exactly one palette is mounted, so nothing read here can be a stale copy.
expect(scan!.palettes).toBe(1)
// The pins are collected under their own heading rather than scattered through
// the categories.
expect(scan!.hasPinnedSection).toBe(true)
await page.screenshot({ path: join(SHOTS, 'palette-pinned-first.png') })
// And the selection starts on a PINNED product, so ↵ on the untouched default
// view goes somewhere the user chose. `models` is pinned out of the box.
await page.keyboard.press('Enter')
await expect.poll(() => new URL(page.url()).pathname, { timeout: 15_000 }).toBe('/models')
await ctx.close()
})
test('the row pin is quiet until reached, and lit while pinned', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await openPalette(page)
await page.getByPlaceholder('Search apps and commands…').fill('agents')
await expect(page.locator('#cmdk-active')).toBeVisible()
// A result that is NOT the keyboard selection: its pin is painted at zero opacity
// — present in the DOM and reachable, but not drawn.
const rows = page.locator('.hz-row-pin')
const count = await rows.count()
let restingOpacity: number | null = null
for (let i = 0; i < count; i++) {
const row = rows.nth(i)
if ((await row.getAttribute('id')) === 'cmdk-active') continue
const quiet = row.locator('.hz-pin')
if ((await quiet.count()) === 0) continue
restingOpacity = await quiet.first().evaluate((el) => Number(getComputedStyle(el).opacity))
// Hovering the ROW reveals it — the affordance appears where the eye already is.
await row.hover()
await expect
.poll(async () => quiet.first().evaluate((el) => Number(getComputedStyle(el).opacity)))
.toBeGreaterThan(0.9)
break
}
expect(restingOpacity).not.toBeNull()
expect(restingOpacity).toBeLessThan(0.05)
// The SELECTED row's pin is drawn without any hover — the keyboard user is never
// shown an empty row where the mouse user is shown a control.
const activePin = page.locator('#cmdk-active').locator('[aria-label^="Pin "], [aria-label^="Unpin "]').first()
const activeOpacity = await activePin.evaluate((el) => Number(getComputedStyle(el).opacity))
expect(activeOpacity).toBeGreaterThan(0.3)
await ctx.close()
})
test('⌘K, arrows, Enter and Escape drive the whole surface', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await boot(page)
await openPalette(page)
const first = await page.locator('#cmdk-active').getAttribute('aria-label').catch(() => null)
const firstText = await page.locator('#cmdk-active').innerText()
// ↓ moves the selection to a different row (the selection is a single element, so
// "moved" is provable by its text changing).
await page.keyboard.press('ArrowDown')
await expect.poll(async () => page.locator('#cmdk-active').innerText()).not.toBe(firstText)
// ↑ returns to it.
await page.keyboard.press('ArrowUp')
await expect.poll(async () => page.locator('#cmdk-active').innerText()).toBe(firstText)
expect(first === null || typeof first === 'string').toBe(true)
await page.screenshot({ path: join(SHOTS, 'palette-keyboard.png') })
// Esc closes.
await page.keyboard.press('Escape')
await expect(page.getByPlaceholder('Search apps and commands…')).toHaveCount(0)
// ↵ on a selection navigates — the palette is a way to ACT, not just to look.
await openPalette(page)
await page.getByPlaceholder('Search apps and commands…').fill('marketplace')
await expect(page.locator('#cmdk-active')).toBeVisible()
await page.keyboard.press('Enter')
await expect.poll(() => page.url(), { timeout: 15_000 }).toContain('/marketplace')
await ctx.close()
})
test('a list keeps the narrowing you gave it, and Reset gives it back', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
const search = page.getByPlaceholder('Search listings, providers, descriptions…')
// Drilled into a product the rail shows that product's sub-nav, not the catalog
// with its pins — so the surface under test is its own readiness signal.
await boot(page, '/marketplace', async () => {
await expect(search).toBeVisible({ timeout: 30_000 })
})
// Nothing is narrowed yet, so Reset is not there. A control that is always present
// but usually inert teaches a user to ignore it.
await expect(page.getByRole('button', { name: 'Reset filters' })).toHaveCount(0)
await search.fill('zen')
const available = page.getByRole('button', { name: 'Available now' })
await available.click()
await expect(available).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByRole('button', { name: 'Reset filters' })).toBeVisible()
await page.screenshot({ path: join(SHOTS, 'list-narrowed.png') })
// Navigate away and back: the view is exactly as it was left. This is the whole
// point of persisting it — a list you must re-narrow on every visit is a list you
// stop narrowing.
await page.goto(`${BASE_URL}/models`, { waitUntil: 'domcontentloaded' })
await expect(page.getByPlaceholder('Search models across every family…')).toBeVisible({ timeout: 30_000 })
await page.goto(`${BASE_URL}/marketplace`, { waitUntil: 'domcontentloaded' })
const back = page.getByPlaceholder('Search listings, providers, descriptions…')
await expect(back).toBeVisible({ timeout: 30_000 })
await expect(back).toHaveValue('zen')
await expect(page.getByRole('button', { name: 'Available now' })).toHaveAttribute('aria-pressed', 'true')
// …and it survives a full reload, like the pins.
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByPlaceholder('Search listings, providers, descriptions…')).toHaveValue('zen', {
timeout: 30_000,
})
// Reset clears every narrowing at once and takes its own control away with it.
await page.getByRole('button', { name: 'Reset filters' }).click()
await expect(page.getByPlaceholder('Search listings, providers, descriptions…')).toHaveValue('')
await expect(page.getByRole('button', { name: 'Available now' })).toHaveAttribute('aria-pressed', 'false')
await expect(page.getByRole('button', { name: 'Reset filters' })).toHaveCount(0)
await ctx.close()
})
test('the list bar reads on the black canvas and never scrolls the page sideways', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
const search = page.getByPlaceholder('Search models across every family…')
// At 390px the rail is a drawer, so the list bar itself is the readiness signal.
await boot(page, '/models', async () => {
await expect(search).toBeVisible({ timeout: 30_000 })
})
await search.fill('zen')
// The placeholder/typed text must actually be legible against what is behind it.
const contrast = await search.evaluate((el) => {
const lum = (c: string) => {
const [r, g, b] = (c.match(/[\d.]+/g) ?? ['0', '0', '0']).slice(0, 3).map(Number)
const f = (v: number) => {
const s = v / 255
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
}
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}
// Walk up for the first non-transparent background actually painted behind it.
let node: HTMLElement | null = el as HTMLElement
let bg = 'rgb(0, 0, 0)'
while (node) {
const c = getComputedStyle(node).backgroundColor
if (c && !c.includes('rgba(0, 0, 0, 0)')) {
bg = c
break
}
node = node.parentElement
}
const fg = getComputedStyle(el as HTMLElement).color
const a = lum(fg)
const b = lum(bg)
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
})
expect(contrast).toBeGreaterThanOrEqual(4.5)
// The body must never scroll sideways at 390px.
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
await page.screenshot({ path: join(SHOTS, 'list-bar-mobile.png') })
await ctx.close()
})
+1 -1
View File
@@ -183,7 +183,7 @@ test.describe('Insights renders on admin.hanzo.ai for the SuperAdmin (authentica
{ id: 'service-map', label: 'Service Map', expect: /Service Map|Rate|Errors|Duration|p99|dependency|Observability|no telemetry|not enabled|initializing/i },
{ id: 'logs', label: 'Logs', expect: /Logs|Application logs|Request activity|Severity|Message|no application logs|Observability|initializing/i },
{ id: 'o11y', label: 'Traces', expect: /Traces|Trace|Latency|Tokens|Cost|Observability|No traces|initializing|not enabled/i },
{ id: 'fleet-o11y', label: 'Fleet Observability', expect: /Fleet Observability|Requests|Tokens|Latency|Top organizations|operator access|not authorized/i },
{ id: 'fleet-o11y', label: 'Fleet Observability', expect: /Fleet Observability|Requests|Tokens|Latency|Top organizations|superadmin access|not authorized/i },
]
for (const m of modules) {
await page.goto(`${surface}/${m.id}`, { waitUntil: 'domcontentloaded' })
+64
View File
@@ -0,0 +1,64 @@
/**
* The OAuth return raises EXACTLY ONE toast.
*
* A unit test cannot see this bug. It is a render loop: the toast provider built
* its context value fresh on every render and used it as the value, so every
* useToast() consumer got a new identity whenever a toast was added — and the
* integrations effect both DEPENDS on the toast api and RAISES a toast. Raising
* one re-rendered the provider, which handed the effect a new api, which raised
* another. Live this stacked ~15 identical "Connected slack" cards down the
* viewport. Stripping the query params could not stop it: router.replace is
* asynchronous, so the params are still readable on the renders in between.
*
* So the assertion is a COUNT after the loop has had time to run, on the real
* rendered DOM — the only place the defect exists.
*/
import { test, expect } from '@playwright/test'
import { primeSession } from './_session'
const PROVIDERS = [
{
id: 'slack',
name: 'Slack',
description: 'Post messages and receive events in your Slack workspace.',
category: 'Communication',
available: true,
connected: true,
connection: { account: 'The Foundation', connectedAt: '2026-08-05T00:16:49Z' },
},
]
test.describe('integrations OAuth return', () => {
test.beforeEach(async ({ page }) => {
// Everything else answers empty so the module mounts standalone.
await page.route('**/v1/**', async (route) => {
const url = route.request().url()
if (url.includes('/v1/integrations')) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PROVIDERS) })
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"data":[]}' })
})
await primeSession(page)
})
test('a connected= return raises exactly one toast', async ({ page }) => {
await page.goto('/integrations?connected=slack&account=The+Foundation')
const toasts = page.getByText('Connected slack')
await expect(toasts.first()).toBeVisible({ timeout: 15_000 })
// Give the loop every chance to run: the effect re-fires on each provider
// re-render, and the pre-fix build had stacked well past a dozen by now.
await page.waitForTimeout(3_000)
expect(await toasts.count()).toBe(1)
await page.screenshot({ path: 'e2e-shots/integrations-one-toast.png', fullPage: false })
})
test('the callback params are stripped so a reload cannot replay it', async ({ page }) => {
await page.goto('/integrations?connected=slack&account=The+Foundation')
await expect(page.getByText('Connected slack').first()).toBeVisible({ timeout: 15_000 })
await expect.poll(() => new URL(page.url()).search, { timeout: 10_000 }).toBe('')
})
})
+97
View File
@@ -0,0 +1,97 @@
/**
* e2e: LEADING — no rendered text may have a line-height smaller than its font-size.
*
* This exists because of a trap that is invisible to every unit test and to every
* type-check: react-native-web's style compiler appends `px` to a numeric style value
* unless the property is on its unitless allow-list — and `lineHeight` is NOT on it
* (`react-native-web/dist/exports/StyleSheet/compiler/unitlessNumbers.js`). React DOM's
* own allow-list DOES include `lineHeight`, so `style={{ lineHeight: 1.12 }}` is a
* correct, idiomatic RATIO in plain React and silently becomes the absurd
* `line-height: 1.12px` under @hanzo/gui (Tamagui/RNW).
*
* The failure mode is not subtle once rendered: the line box collapses to ~1px, the
* heading's descenders fall into whatever sits beneath it, and the element above is
* clipped. It shipped on every product landing (the guide PitchHero headline).
*
* So this asserts the INVARIANT rather than the one call site — every visible text node
* on the surface must have `line-height >= font-size` — which catches the next numeric
* lineHeight anyone writes, anywhere, without them having to know about RNW's list.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test leading
*/
import { test, expect, type Page, type Route } from '@playwright/test'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
requireFixtureServer()
const API_RE = /\/(v1|cloud|ai|auth|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
/** Everything the shell asks for answers an empty-ok envelope — this spec measures TYPE, not data. */
async function stub(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
if (!API_RE.test(new URL(req.url()).pathname)) return route.continue()
return route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true,"items":[],"data":[]}' })
}
/** Every visible text node whose computed line-height is smaller than its own font-size. */
async function collapsedLines(page: Page) {
return page.evaluate(() => {
const bad: { text: string; fontSize: string; lineHeight: string; height: number }[] = []
document.querySelectorAll('*').forEach((el) => {
if (el.children.length) return
const text = (el as HTMLElement).innerText?.trim()
if (!text) return
const cs = getComputedStyle(el)
const size = parseFloat(cs.fontSize)
const lead = parseFloat(cs.lineHeight) // `normal` → NaN, which is never a defect
if (!Number.isFinite(lead) || !Number.isFinite(size) || lead >= size) return
bad.push({
text: text.slice(0, 60),
fontSize: cs.fontSize,
lineHeight: cs.lineHeight,
height: Math.round(el.getBoundingClientRect().height),
})
})
return bad
})
}
for (const path of ['/models', '/agents', '/playground']) {
test(`no collapsed line box on ${path}`, async ({ page }) => {
await page.route('**/*', stub)
await primeSession(page)
await page.goto(path, { waitUntil: 'domcontentloaded' })
await page.waitForTimeout(2500)
expect(await collapsedLines(page)).toEqual([])
})
}
test('the product guide headline leads its own subhead', async ({ page }) => {
await page.route('**/*', stub)
await primeSession(page)
await page.goto('/models', { waitUntil: 'domcontentloaded' })
const guide = page.getByTestId('product-guide')
await expect(guide).toBeVisible({ timeout: 20_000 })
const box = await guide.evaluate((g) => {
const texts = [...g.querySelectorAll('*')].filter(
(e) => !e.children.length && (e as HTMLElement).innerText?.trim(),
) as HTMLElement[]
const headline = texts.reduce((a, b) =>
parseFloat(getComputedStyle(b).fontSize) > parseFloat(getComputedStyle(a).fontSize) ? b : a,
)
const cs = getComputedStyle(headline)
return {
fontSize: parseFloat(cs.fontSize),
lineHeight: parseFloat(cs.lineHeight),
height: headline.getBoundingClientRect().height,
}
})
// A display headline leads between 1.0 and 1.5 — and its box is at least one line tall.
expect(box.lineHeight).toBeGreaterThanOrEqual(box.fontSize)
expect(box.lineHeight).toBeLessThanOrEqual(box.fontSize * 1.5)
expect(box.height).toBeGreaterThanOrEqual(box.fontSize)
})
+219
View File
@@ -0,0 +1,219 @@
/**
* e2e: ONE level-2 nav.
*
* Clicking into a product must reveal ITS options rather than replacing the screen,
* and there must be exactly ONE such nav on screen — not the sidebar's level 2 AND a
* competing tab strip in the content, which is what `/models` used to do (eight items
* in the rail, four in the content, disagreeing on the index's own name).
*
* "Rather than replacing the screen" is now literal on both axes: the product's
* sub-pages expand BENEATH its row and the rest of the catalog stays put. The rail
* used to swap itself for the product's sub-nav behind a "Back to all products"
* button, so these specs assert the other products are still there — that is the
* whole point of the change, and the part a future drill would silently undo.
*
* These are assertions only a browser can make. They read COMPUTED style and
* GEOMETRY, not source: a strip hidden by a `$lg` media style prop is still in the
* DOM, so `toBeVisible()` — which resolves to `display`/`visibility`/box-size — is
* the only honest test of "is there a second nav on screen".
*
* Run: BASE_URL=http://localhost:4111 npx playwright test level-2-nav
*/
import { test, expect, type Route, type Page } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const ACCOUNT = {
owner: 'hanzo',
name: 'z',
email: 'z@hanzo.ai',
displayName: 'Z Admin',
isAdmin: true,
}
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
const json = (route: Route, body: unknown, status = 200) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
/**
* Every backend answers 401 — this spec is about NAV, not data, and an unauthorized
* read is the state every module already handles honestly. (A fabricated empty
* envelope is NOT interchangeable: a module that expects an object and is handed
* `[]` throws into its error boundary, which would make this spec a data test.)
*/
async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (url.pathname.startsWith('/auth/')) return json(route, { ok: true })
const sameOrigin = url.origin === new URL(BASE_URL).origin
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
return json(route, { error: 'Sign in to use Hanzo Cloud.' }, 401)
}
async function open(page: Page, path: string) {
await page.route('**/*', mock)
await primeSession(page, ACCOUNT)
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
await page.waitForTimeout(1200)
}
/** The level-2 nav rendered in the CONTENT column (`SubNav`). Located by test id,
* not by role: a `display: none` element leaves the accessibility tree, and this
* spec must be able to find it precisely when it is hidden. */
const strip = (page: Page, id: string) => page.locator(`[data-testid="subnav-${id}"]`)
/** A level-2 row/tab by its label, anywhere on screen, VISIBLE only. */
const visibleTab = (page: Page, label: string) =>
page.getByRole('button', { name: label, exact: true }).filter({ visible: true })
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test('desktop: the sidebar owns level 2 — the content strip is not a second nav', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page, '/models')
// The rail expanded Models in place — and did NOT swap itself for it.
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
// The rest of the catalog is still there — "All products" sits at the FOOT of the
// product list, so its presence proves the list was never swapped away. This is the
// assertion the drill could not have passed.
await expect(page.getByRole('button', { name: 'All products' }).first()).toBeVisible()
// The index is named what the PRODUCT calls it — Models' index is the Catalog,
// not a generic "Overview". This is the registry's `indexLabel`, read by the nav.
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
await expect(visibleTab(page, 'Leaderboard').first()).toBeVisible()
await expect(visibleTab(page, 'Blend').first()).toBeVisible()
// Exactly ONE of each — a duplicate would mean two navs painting at once.
for (const label of ['Catalog', 'Leaderboard', 'Blend']) {
expect(await visibleTab(page, label).count(), `${label} appears once`).toBe(1)
}
// The content strip is in the DOM but PAINTS NOTHING at lg+ (computed display).
await expect(strip(page, 'models')).toBeAttached()
await expect(strip(page, 'models')).not.toBeVisible()
expect(await strip(page, 'models').evaluate((el) => getComputedStyle(el).display)).toBe('none')
await page.screenshot({ path: join(SHOTS, 'level2-desktop-models.png'), fullPage: false })
await ctx.close()
})
test('phone: the strip carries level 2 where the sidebar is a drawer', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await open(page, '/models')
// The rail is off-canvas, so the strip is the ONE nav — and it is the SAME list.
await expect(strip(page, 'models')).toBeVisible()
const labels = await strip(page, 'models').getByRole('button').allInnerTexts()
// Routing is admin-only and this account is an ORG admin, not a global one — the
// one nav gates it, so a customer is never offered a surface they cannot open.
expect(labels).toEqual(['Catalog', 'Leaderboard', 'Blend', 'Settings', 'Status', 'Logs', 'Metrics'])
// The strip wraps rather than pushing the page sideways.
const scrolls = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
)
expect(scrolls, 'body must not scroll horizontally').toBe(false)
// Every tab is a real hit target — measured, not assumed.
const boxes = await strip(page, 'models').getByRole('button').all()
for (const b of boxes) {
const box = await b.boundingBox()
expect(box, 'a tab must have a painted box').not.toBeNull()
expect(box!.height, 'a tab must be tall enough to tap').toBeGreaterThanOrEqual(28)
expect(box!.x + box!.width, 'a tab must not paint past the right edge').toBeLessThanOrEqual(391)
}
await strip(page, 'models').scrollIntoViewIfNeeded()
await page.screenshot({ path: join(SHOTS, 'level2-mobile-models.png'), fullPage: false })
await ctx.close()
})
test('the URL carries the level — a deep link and a reload land on the same tab', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await ctx.newPage()
await open(page, '/models/blend')
const current = async () =>
strip(page, 'models').locator('[aria-current="page"]').first().innerText()
expect(await current()).toBe('Blend')
await page.reload({ waitUntil: 'domcontentloaded' })
await page.locator('[data-testid="product-content"]').first().waitFor({ state: 'attached', timeout: 30_000 })
await page.waitForTimeout(1200)
expect(await current(), 'reload keeps the level').toBe('Blend')
expect(new URL(page.url()).pathname).toBe('/models/blend')
await ctx.close()
})
test('back returns a level without losing pinned state', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await open(page, '/models')
// Pin state is account-backed, not view state — it must survive a drill + back.
const pinsBefore = await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))
await visibleTab(page, 'Blend').first().click()
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/models/blend')
await page.goBack({ waitUntil: 'domcontentloaded' })
await page.waitForTimeout(900)
expect(new URL(page.url()).pathname).toBe('/models')
// Models is still expanded with the same options — browser Back moved the ROUTE,
// and the rail followed it without collapsing what the user was looking at.
await expect(visibleTab(page, 'Catalog').first()).toBeVisible()
await expect(page.getByRole('button', { name: 'Back to all products' })).toHaveCount(0)
expect(await page.evaluate(() => localStorage.getItem('hanzo.preferences.cache'))).toBe(pinsBefore)
await ctx.close()
})
/**
* Every product that used to carry its own `const TABS` — the whole conversion, in
* one sweep. For each: the page renders, the rail expands it in place, and the
* content strip is present but PAINTS NOTHING at lg+. That is the "no second nav"
* invariant, and it is the thing that regresses the moment someone adds a tab bar
* back.
*/
const CONVERTED = [
'models', 'evals', 'ai-accounts', 'containers', 'analytics', 'finetuning', 'team',
'automations', 'embeddings', 'tasks', 'functions', 'profile', 'router', 'settings',
'zero-trust', 'billing', 'captable', 'crm',
] as const
test('no product paints a second level-2 nav at lg+', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
for (const id of CONVERTED) {
await open(page, `/${id}`)
await expect(strip(page, id), `${id}: declares one level-2 nav`).toBeAttached()
expect(
await strip(page, id).evaluate((el) => getComputedStyle(el).display),
`${id}: the content strip must not paint while the rail owns level 2`,
).toBe('none')
await expect(
page.getByRole('button', { name: 'Back to all products' }),
`${id}: the rail expands in place — it must never swap itself for one product`,
).toHaveCount(0)
}
await ctx.close()
})
+1 -1
View File
@@ -134,7 +134,7 @@ test.describe('LIVE v8.4.15 — (a) business board + (c) billing dimension', ()
test('(c) billing Reports renders the cost-dimension surface', async ({ page }) => {
await signIn(page, CONSOLE)
// v8.4.16: the data proxy moved to /billing/v1/*, so /billing/reports now falls
// The data proxy lives at /v1/billing/*, so /billing/reports now falls
// through to the SPA (was shadowed by the /billing/[...path] proxy → raw JSON).
// A hard deep-link must render the Reports UI, not a proxy "not found".
await page.goto(`${CONSOLE}/billing/reports`, { waitUntil: 'domcontentloaded' })
+118
View File
@@ -0,0 +1,118 @@
/**
* Onboarding — Continue never moves, and Skip is always reachable.
*
* The complaint this pins: the Continue button landed at a different height on
* every step, so a user clicking through had to re-aim each time. StepActions was
* the LAST CHILD of a flex column, so its y was whatever the step's content
* happened to add up to. It is now a SLOT on StepShell above a content area with
* a reserved height — one placement, decided in one place.
*
* This is a GEOMETRY assertion on purpose. The JSX move is invisible to a unit
* test (both shapes render the same button with the same label); only the painted
* box says whether the thing the user complained about is fixed.
*/
import { test, expect, type Page } from '@playwright/test'
import { primeSession } from './_session'
/** Every step whose footer must line up, in flow order. */
const STEPS = ['Secure your account', 'Data & consent', 'Your organization', 'Free trial credits', 'AI access']
/** The y of the actions row, in page coordinates. */
async function actionsY(page: Page): Promise<number> {
const row = page.getByTestId('onboarding-actions')
await expect(row).toBeVisible()
const box = await row.boundingBox()
if (!box) throw new Error('actions row has no box')
return Math.round(box.y)
}
/** Advance past the current step, preferring Skip so the flow stays clickable. */
async function advance(page: Page): Promise<void> {
const row = page.getByTestId('onboarding-actions')
const skip = row.getByRole('button', { name: /^(Skip|Keep the default)/ })
if (await skip.count()) {
await skip.first().click()
return
}
// Consent has no Skip by design (accepting Terms is not optional), so tick the
// agreement and use Continue. Tick only if Continue is still disabled — a caller
// may already have ticked it, and toggling twice turns it back OFF.
const cont = row.getByRole('button', { name: /Continue/ })
if (await cont.isDisabled()) {
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
if (await agree.count()) await agree.click()
}
await cont.click()
}
test.beforeEach(async ({ page }) => {
// Anything the steps reach for answers empty — they are best-effort and must
// still render. Registered BEFORE primeSession so its handlers win.
await page.route('**/v1/**', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
await primeSession(page)
// primeSession marks onboarding DONE so other specs can reach the app. This
// spec is about the wizard, so un-mark it (the tour gate stays seeded).
await page.addInitScript(() => {
for (const k of Object.keys(localStorage)) if (k.startsWith('hz_onboarding_done:')) localStorage.removeItem(k)
})
})
test('Continue lands at the same height on every step', async ({ page }) => {
await page.goto('/')
const seen: { step: string; y: number }[] = []
for (const step of STEPS) {
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
seen.push({ step, y: await actionsY(page) })
await advance(page)
}
const ys = seen.map((s) => s.y)
const spread = Math.max(...ys) - Math.min(...ys)
expect(
spread,
`Continue moved ${spread}px across steps — ${seen.map((s) => `${s.step}:${s.y}`).join(' ')}`,
).toBeLessThanOrEqual(2)
})
test('every step always offers an enabled way forward', async ({ page }) => {
await page.goto('/')
// The real invariant behind "skip so easy to click through": on every step there
// is ALWAYS at least one enabled control that advances you — Skip where there is
// something to decline, Continue where there is not. Credits swaps between the
// two on purpose (Skip appears only when a card could be added; otherwise
// Continue carries you), so asserting a literal "Skip" everywhere would be
// asserting the wrong thing. Being STUCK is the defect.
for (const step of STEPS) {
await expect(page.getByTestId('onboarding-step-title')).toHaveText(step, { timeout: 15_000 })
// Consent gates Continue on accepting the Terms — not optional, so tick it
// first and then assert the way forward exists.
if (step === 'Data & consent') {
const agree = page.locator('[role="switch"], input[type="checkbox"]').first()
if (await agree.count()) await agree.click()
}
const row = page.getByTestId('onboarding-actions')
const forward = row.getByRole('button', { name: /^(Skip|Keep the default|Continue)/ })
const n = await forward.count()
expect(n, `${step} renders no forward control`).toBeGreaterThan(0)
let usable = 0
for (let i = 0; i < n; i++) {
const b = forward.nth(i)
if (await b.isDisabled()) continue
const box = await b.boundingBox()
if (!box || box.height < 24) continue
const hit = await b.evaluate((el) => {
const r = el.getBoundingClientRect()
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2))
})
if (hit) usable++
}
expect(usable, `${step} has no enabled, clickable way forward`).toBeGreaterThan(0)
await advance(page)
}
})
+153
View File
@@ -0,0 +1,153 @@
/**
* e2e: the console's ORG identity in the chrome — mocked-network render proof.
*
* Two things this pins, both of which the shipped console got wrong:
*
* 1. The top-left mark is the ORG's, never the house glyph. With a logo it is
* that logo; with none it is the org's MONOGRAM — the treatment the account
* widget gives a person — and NOT the brand mark, and NOT the org's name set
* as running text.
* 2. The org switcher is the PEER of the account control: same height, same
* mark size, same type, same hit area, same left edge.
*
* Both are measured off the RENDERED boxes, not off class names, so a styling
* regression fails here.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test org-identity
*/
import { test, expect, type Page, type Route } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
const SHOTS = join(process.cwd(), 'e2e-shots')
requireFixtureServer()
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
/** A tenant whose id carries a separator — its monogram must read AL, not A. */
const ORG = 'acme-labs'
const LOGO = 'https://cdn.example.test/acme-labs.png'
const API_RE = /\/(v1|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
const envelope = (data: unknown) => JSON.stringify({ status: 'ok', msg: '', data, data2: 0 })
/** Mount the shell as a member of `acme-labs`; `logo` decides which mark shows. */
async function openShell(page: Page, logo: string | null) {
await page.route('**/*', async (route: Route) => {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
// The ONE org read the chrome makes (`useOrgIdentity` → get-organization).
if (url.pathname.endsWith('/v1/iam/get-organization')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: envelope({ owner: 'admin', name: ORG, displayName: 'Acme Labs', logo: logo ?? '' }),
})
}
// The logo bytes — a 1x1 PNG, so the <img> genuinely paints.
if (url.href === LOGO) {
return route.fulfill({
status: 200,
contentType: 'image/png',
body: Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
),
})
}
if (url.origin === new URL(BASE_URL).origin && !API_RE.test(url.pathname)) return route.continue()
return route.fulfill({ status: 200, contentType: 'application/json', body: envelope([]) })
})
// A plain tenant member (owner !== 'admin'), i.e. NOT a super admin — the case
// that has no cross-tenant org list to draw its own row from.
await primeSession(page, { owner: ORG, name: 'dave', email: 'dave@acme.test', displayName: 'Dave Lorenzini', isAdmin: false })
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: /account menu/i }).first()).toBeVisible({ timeout: 30_000 })
}
const orgMark = (page: Page) => page.getByRole('link', { name: /— home/ }).first()
const orgTrigger = (page: Page) => page.getByRole('button', { name: /switch organization/i }).first()
const accountTrigger = (page: Page) => page.getByRole('button', { name: /account menu/i }).first()
test('the top-left mark is the org monogram — never the house mark, never the name as text', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await openShell(page, null)
const mark = orgMark(page)
await expect(mark).toBeVisible()
// The monogram of the org's DISPLAY name, by the account widget's own rule.
await expect(mark).toHaveText('AL')
// Not the house glyph: the slot paints no SVG at all.
expect(await mark.locator('svg').count()).toBe(0)
// Not the org name as running text.
await expect(mark).not.toContainText('Acme Labs')
await page.screenshot({ path: join(SHOTS, 'org-identity-monogram.png') })
await ctx.close()
})
test('the orgs OWN logo replaces the mark when IAM carries one', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await openShell(page, LOGO)
const logo = orgMark(page).locator('img')
await expect(logo).toHaveAttribute('src', LOGO)
// The logo REPLACES the monogram — one mark, not both.
await expect(orgMark(page)).toHaveText('')
expect(await orgMark(page).locator('svg').count()).toBe(0)
await page.screenshot({ path: join(SHOTS, 'org-identity-logo.png') })
await ctx.close()
})
test('the org switcher reads as the peer of the account control', async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
await openShell(page, null)
const org = orgTrigger(page)
const account = accountTrigger(page)
await expect(org).toBeVisible()
await expect(account).toBeVisible()
const [orgBox, accountBox] = [await org.boundingBox(), await account.boundingBox()]
if (!orgBox || !accountBox) throw new Error('a switcher did not lay out')
// Same height, same width, same left edge — one hit area, one column.
expect(Math.round(orgBox.height)).toBe(Math.round(accountBox.height))
expect(Math.abs(orgBox.width - accountBox.width)).toBeLessThanOrEqual(1)
expect(Math.abs(orgBox.x - accountBox.x)).toBeLessThanOrEqual(1)
// A real target, not a caption.
expect(orgBox.height).toBeGreaterThanOrEqual(44)
// Same type: the org name and the account name are set identically.
const type = (root: typeof org, name: string) =>
root.locator(`text=${name}`).first().evaluate((el) => {
const s = getComputedStyle(el)
return { size: s.fontSize, weight: s.fontWeight }
})
expect(await type(org, 'Acme Labs')).toEqual(await type(account, 'Dave Lorenzini'))
// Same mark size — the org monogram tile matches the account avatar tile.
const tile = async (root: typeof org) => {
const b = await root.locator('div,span').filter({ hasText: /^(AL|DL)$/ }).last().boundingBox()
return b ? { w: Math.round(b.width), h: Math.round(b.height) } : null
}
expect(await tile(org)).toEqual(await tile(account))
await page.screenshot({ path: join(SHOTS, 'org-identity-peers.png') })
// The sidebar column alone — the two controls, top and bottom, side by side.
await page.screenshot({ path: join(SHOTS, 'org-identity-sidebar.png'), clip: { x: 0, y: 0, width: 300, height: 900 } })
await ctx.close()
})
+75
View File
@@ -0,0 +1,75 @@
/**
* Playground layout: the Response panel sits UNDER the surface tabs — above
* the composer — at every width. Render-proven on the local dev server with a
* fully mocked network (no gateway, no billing, no catalog): what is asserted
* is GEOMETRY, which mocks cannot fake.
*
* Run: BASE_URL=http://localhost:4000 npx playwright test playground-responsive
*/
import { test, expect, type Route } from '@playwright/test'
import { requireFixtureServer } from './_fixture'
import { primeSession } from './_session'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
// The module shell resolves the product registry from the local fixture server,
// like every other module render spec; skip cleanly when it is down.
requireFixtureServer()
const SHOTS = join(process.cwd(), 'e2e-shots')
const WIDTHS = [
{ name: 'phone', width: 390, height: 844 },
{ name: 'tablet', width: 834, height: 1112 },
{ name: 'laptop', width: 1440, height: 900 },
{ name: 'desktop', width: 1920, height: 1080 },
]
// Minimal honest bodies for everything the page asks the backend.
const mock = async (route: Route) => {
const url = route.request().url()
const json = (body: unknown) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
if (url.includes('/pricing/models')) return json({ models: [] })
if (url.includes('/v1/models'))
return json({ object: 'list', data: [{ id: 'zen5-flash', owned_by: 'Hanzo' }] })
if (url.includes('/billing/subscriptions')) return json({ subscriptions: [] })
if (url.includes(':4000') || url.startsWith(BASE_URL)) return route.continue()
return json({})
}
for (const vp of WIDTHS) {
test(`response renders under the tabs at ${vp.name} (${vp.width}px)`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await page.route('**/*', mock)
await primeSession(page)
await page.goto(`${BASE_URL}/ai/playground`, { waitUntil: 'domcontentloaded' })
// The three landmarks: the surface tabs, the Response panel, the composer.
const tabs = page.getByRole('button', { name: 'Completions' }).first()
const response = page.getByText('Response', { exact: true }).first()
const composer = page.getByText('System prompt', { exact: true }).first()
await expect(tabs).toBeVisible({ timeout: 20000 })
await expect(response).toBeVisible()
await expect(composer).toBeVisible()
const [tabsBox, respBox, compBox] = await Promise.all([
tabs.boundingBox(),
response.boundingBox(),
composer.boundingBox(),
])
if (!tabsBox || !respBox || !compBox) throw new Error('a landmark has no box')
// ORDER: tabs, then Response, then the composer — at every width.
expect(respBox.y, 'Response sits below the tabs').toBeGreaterThan(tabsBox.y)
expect(compBox.y, 'the composer sits below the Response panel top').toBeGreaterThan(respBox.y)
// RESPONSIVE: nothing forces a horizontal scroll.
const scrollW = await page.evaluate(() => document.documentElement.scrollWidth)
expect(scrollW, 'no horizontal overflow').toBeLessThanOrEqual(vp.width + 1)
mkdirSync(SHOTS, { recursive: true })
await page.screenshot({ path: join(SHOTS, `playground-${vp.name}-${vp.width}.png`) })
})
}
+1 -1
View File
@@ -188,7 +188,7 @@ test.describe('(B) LIVE — admin gate + real DO data', () => {
await expect(page).not.toHaveURL(/\/signin/, { timeout: 15_000 })
// The board (not the operator-access gate) rendered for the SuperAdmin.
await expect(page.locator('text=/Provider credit|Credit vs paid/i').first()).toBeVisible({ timeout: 30_000 })
await expect(page.locator('text=/Operator access required/i')).toHaveCount(0)
await expect(page.locator('text=/SuperAdmin access required/i')).toHaveCount(0)
// Real DO data: the $26k grant / a do-ai card / glm-5.2 usage.
await expect(page.locator('text=/do-ai|digitalocean/i').first()).toBeVisible({ timeout: 30_000 })
await page.screenshot({ path: join(SHOTS, 'provider-billing-live-do.png'), fullPage: true })
+108
View File
@@ -0,0 +1,108 @@
import { expect, test } from '@playwright/test'
/**
* The research dashboard — every benchmark run and its verdict.
*
* What this file can and cannot prove, stated plainly, because the difference is the
* whole value:
*
* The console is a SPA behind a catch-all, so EVERY path returns HTTP 200 — including
* `/definitely-not-a-page`. A test asserting "200" or "the page loaded" passes even when
* the route is deleted. And `/research` is behind `AuthGate`, so an anonymous visitor is
* redirected to sign-in and sees NEITHER the dashboard NOR the SuperAdmin gate. Measured,
* not assumed: the body reads "Sign in to your account".
*
* So the two tests that RUN here prove security and routing, not rendering:
* - anonymous callers see no corpus data (the gate genuinely holds)
* - a nonsense path renders no dashboard (the assertion above can fail)
* Proving the dashboard PAINTS needs a SuperAdmin session, so that test is staged behind
* HANZO_PASSWORD rather than faked — the same staging `insights-o11y.spec.ts` uses.
*
* Run: BASE_URL=https://cloud.hanzo.ai npx playwright test research-dashboard
*/
const BASE_URL = process.env.BASE_URL ?? 'https://cloud.hanzo.ai'
/** Copy rendered ONLY by ResearchModule. */
const DASHBOARD = /Falsifiable R&D experiments/i
/** Corpus numbers. Never visible to a caller who is not a SuperAdmin. */
const CORPUS = [/\bProven\b/, /\bRefuted\b/, /\bAttempts\b/]
async function settle(page: import('@playwright/test').Page) {
await page.waitForLoadState('domcontentloaded')
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {})
}
test.describe('research dashboard', () => {
test('an anonymous caller sees no corpus data', async ({ page }) => {
// The security assertion. The module gates on useIsSuperAdmin and the `research`
// head is org-scoped server-side by the Bearer owner — so an unauthenticated
// visitor must reach sign-in with zero experiment counts painted. A client-only
// gate that rendered the KPI band behind a card would fail here.
await page.goto(`${BASE_URL}/research`)
await settle(page)
const body = page.locator('body')
// ANCHOR FIRST. Every assertion below is an absence, and an absence is satisfied by
// a blank page, a 502, or a dead host — so prove the app actually rendered before
// claiming the gate held. Without this the test passes while the site is down.
await expect(
body.getByText(/Sign in|Log in/i).first(),
'the console shell did not render — the absence assertions below would be vacuous',
).toBeVisible({ timeout: 15_000 })
for (const label of CORPUS) {
await expect(
body.getByText(label),
`"${label}" rendered to an anonymous caller — corpus data leaked past the gate`,
).toHaveCount(0)
}
await expect(
body.getByText(DASHBOARD),
'the dashboard rendered to an anonymous caller — the auth gate is not holding',
).toHaveCount(0)
})
test('is not a catch-all — a nonsense path renders no dashboard', async ({ page }) => {
// The control that gives the authenticated test (below) its meaning: the SPA
// answers 200 here too, so if this path could show the research surface, matching
// on that copy would prove nothing about routing.
await page.goto(`${BASE_URL}/definitely-not-a-page-9137`)
await settle(page)
// Same anchor: the app must have rendered for "no dashboard here" to mean anything.
await expect(
page.locator('body').getByText(/Sign in|Log in/i).first(),
'the console shell did not render — the absence assertion below would be vacuous',
).toBeVisible({ timeout: 15_000 })
await expect(
page.locator('body').getByText(DASHBOARD),
'a nonsense path rendered the research dashboard — matching on that copy proves nothing',
).toHaveCount(0)
})
test('renders the corpus for a SuperAdmin', async ({ page }) => {
// STAGED, not skipped-and-forgotten: needs the reserved-admin SuperAdmin
// credential, which lives in KMS and not on a dev host. With it, this is the
// assertion that actually proves the dashboard paints real evidence.
const password = process.env.HANZO_PASSWORD
test.skip(!password, 'set HANZO_PASSWORD (reserved-admin SuperAdmin) to run the render proof')
await page.goto(`${BASE_URL}/signin`)
await settle(page)
await page.getByLabel(/email|username/i).first().fill(process.env.HANZO_USER ?? 'z@hanzo.ai')
await page.getByLabel(/password/i).first().fill(password!)
await page.getByRole('button', { name: /sign in|log in/i }).first().click()
await page.waitForURL((u) => !u.pathname.startsWith('/signin'), { timeout: 30_000 })
await page.goto(`${BASE_URL}/research`)
await settle(page)
await expect(
page.locator('body').getByText(DASHBOARD),
'a SuperAdmin did not get the research dashboard',
).toHaveCount(1)
await expect(page.locator('body').getByText(/\bExperiments\b/)).not.toHaveCount(0)
})
})
-1
View File
@@ -18,7 +18,6 @@
"agents",
"inference",
"finetuning",
"ml-pipelines",
"embeddings",
"evals",
"gpus",
+3 -3
View File
@@ -128,11 +128,11 @@ async function mock(route: Route) {
async function openPolicy(page: Page, marker = 'Enabled models') {
await page.addInitScript((org) => {
try {
// A valid @hanzo/iam session: a non-expired access token in sessionStorage so the
// A valid @hanzo/iam session: a non-expired access token in localStorage so the
// SDK's getValidAccessToken() returns it (userinfo is network-mocked to CLAIMS).
// Without a future `expires_at` the SDK treats the token as expired → anonymous.
sessionStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
sessionStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
localStorage.setItem('hanzo_iam_access_token', 'mock-access-token')
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600000))
localStorage.setItem('hanzo.console.org', org)
// Scope shows the org PICKER until an org is explicitly entered — the scope
+3 -3
View File
@@ -30,10 +30,10 @@ const SNAPSHOT = {
datastore: { name: 'datastore-data-datastore-0', mount: '/var/lib/hanzo-datastore', sizeGiB: 200, usedGiB: 13.5, pct: 7 },
volumes: [
{ id: 'vol-1', name: 'pvc-datastore', region: 'sfo3', sizeGiB: 200, usedGiB: 13.5, pct: 7, attached: true, service: 'datastore-0' },
{ id: 'vol-2', name: 'pvc-signoz', region: 'sfo3', sizeGiB: 100, usedGiB: 91, pct: 91, attached: true, service: 'signoz' },
{ id: 'vol-2', name: 'pvc-o11y', region: 'sfo3', sizeGiB: 100, usedGiB: 91, pct: 91, attached: true, service: 'o11y' },
{ id: 'vol-3', name: 'pvc-detached', region: 'sfo3', sizeGiB: 50, usedGiB: null, pct: null, attached: false, service: null },
],
alerts: [{ volume: 'signoz', pct: 91, level: 'critical' }],
alerts: [{ volume: 'o11y', pct: 91, level: 'critical' }],
}
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
@@ -43,7 +43,7 @@ async function mock(route: Route) {
const req = route.request()
if (req.resourceType() === 'document') return route.continue()
const url = new URL(req.url())
if (/\/v1\/admin\/block-storage(\/|$|\?)/.test(url.pathname)) {
if (/\/v1\/admin\/volumes(\/|$|\?)/.test(url.pathname)) {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SNAPSHOT) })
}
const sameOrigin = url.origin === new URL(BASE_URL).origin
+10 -22
View File
@@ -1,23 +1,11 @@
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from '@hanzo/gui'
/**
* The console's GUI config IS the shared one. The type/radius/space scale used to be
* declared here; it now ships with the components it scales (`@hanzo/ui/gui-config`),
* because the dedicated Hanzo Social app renders the same @hanzo/ui/product set and a
* second copy of the ladder would fork silently — same components, different sizes.
*
* Kept as a file so `~/gui.config` stays the console's one import path.
*/
export { config, default } from '@hanzo/ui/gui-config'
// Canonical Hanzo UI face: Geist Sans (loaded via the CDN @import in app/globals.css,
// parallel to Geist Mono for code/data). Override the @hanzo/gui (Tamagui) v5 default
// system-font family on the body + heading fonts so every <Text>/<Paragraph>/<H*>
// renders Geist — one place, whole product (DRY). Size/line-height/weight scales are
// inherited from the default config; only the family swaps. Falls back to system-ui
// if the Geist face is unavailable, so the UI degrades gracefully.
const GEIST = "'Geist', system-ui, -apple-system, sans-serif"
export const config = createGui({
...defaultConfig,
fonts: {
...defaultConfig.fonts,
body: { ...defaultConfig.fonts.body, family: GEIST },
heading: { ...defaultConfig.fonts.heading, family: GEIST },
},
})
export default config
export type Conf = typeof config
export type Conf = typeof import('@hanzo/ui/gui-config').config
+30 -22
View File
@@ -1,34 +1,42 @@
# Canonical CI config for hanzoai/console — read by the hanzoai/ci reusable
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai.
# (.hanzo/workflows/cicd.yml) and platform.hanzo.ai. hanzoai/ci pushes to `repo:`
# (GHCR) and server-side-mirrors to registry.hanzo.ai automatically.
#
# Publishes the console STATIC EMBED artifact (SPA static export at /dist) as a
# versioned immutable image. hanzoai/cloud consumes it via `FROM ... AS console`
# + `COPY --from=console /dist/`, so it never rebuilds npm+Next on a cloud release.
# hanzoai/ci pushes to `repo:` (GHCR) and server-side-mirrors to registry.hanzo.ai
# automatically.
# TWO artifacts, one bundle. The console is a static SPA export; what differs is
# only who serves it:
#
# BOTH console images are declared here — one config, read by whichever runner
# executes it. The Next.js SERVER image (admin.hanzo.ai, operator CR
# universe:crs/console.yaml) was built by .github/workflows/build-image.yml until
# that file was neutralized on 2026-07-24 in favour of a native pipeline that
# could not run: hanzoai/console had the forge Actions unit DISABLED
# (`has_actions: false`, zero runs), so nothing built it — v8.5.23 and 8.5.24
# shipped no image, and the CR still pins the last one built, v8.5.22. Declaring
# both images here puts them on the ONE pipeline, wherever it executes.
# console-embed the bundle alone at /dist. hanzoai/cloud does
# `COPY --from=console /dist/` so a cloud release never rebuilds
# npm+Next. Needed only while cloud go:embeds the console.
# console the bundle behind hanzoai/static, serving itself. This is how
# a console change ships WITHOUT a cloud release: move image.tag
# in a universe values file and cd rolls it.
#
# Tag shape changes with the builder, deliberately: the shared builder publishes
# the immutable `sha-<sha7>-amd64` per main push (plus the bare semver on a v*
# tag), not the `:v<X.Y.Z>` receipt the old bespoke workflow minted. Pin the CR to
# the sha tag — that is what hanzoai/cloud does, and an immutable digest-shaped
# tag cannot be re-pushed to different bytes the way `:v8.4.118` once was.
# The Next.js SERVER image that used to be the second entry is gone. It was
# already doing nothing a file server could not — every host it served sent /v1
# and /zap to cloud-api at the ingress, so its BFF was never reached — and its own
# auth routes stopped mattering when identity became a client-held IAM token.
#
# TAGS: the shared builder publishes `sha-<sha7>-amd64` on every main push AND the
# bare semver on a cut v* tag. PIN THE SEMVER — it says which console RELEASE a
# deployment carries, which a sha cannot. The discipline that keeps that honest is
# that a cut tag is never re-pointed (`:v8.4.118` once was): cut the next patch
# instead.
images:
- name: console-embed
context: .
dockerfile: Dockerfile.embed
repo: ghcr.io/hanzoai/console-embed
# The brand-agnostic Next.js server image: brand resolves at RUNTIME from the
# request hostname, so no NEXT_PUBLIC_* may be baked (baking pins the image to
# one brand). SOURCE_COMMIT is the only build arg it ever took.
# The console. It is static — that is not a variant, it is what the console IS,
# so the image is `console` and there is no adjective in the name. Dockerfile
# builds the SPA export and puts hanzoai/static in front of it.
#
# This REPLACES the Next.js server image that used to be published here. It was
# already doing nothing a file server could not: every host it serves
# (admin.lux.cloud, admin.lux.network, admin.zoo.cloud) sends /v1 and /zap to
# cloud-api at the ingress, so the server's BFF at /v1/* was never reached on any
# of them. Its own auth routes went the same way when identity became a
# client-held IAM token. One console, one image.
- name: console
context: .
dockerfile: Dockerfile
+50 -10
View File
@@ -1,4 +1,4 @@
import { readdirSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
@@ -7,12 +7,16 @@ import { resolveBuildId, readGitSha } from './src/config/build-id.mjs'
/**
* Hanzo Cloud Console — Next.js config.
*
* Hanzo GUI is consumed at runtime (no optimizing compiler): the published
* `@hanzogui/next-plugin` has a broken npm dependency (`hanzogui-loader@7.3.0`
* is unpublished; the available fork renames its exports), so we transpile the
* Gui ESM packages with Next's built-in `transpilePackages` and let
* `GuiProvider` inject CSS at runtime. Gui is designed to work this way — the
* compiler is an optimization, not a requirement.
* Hanzo GUI is consumed at runtime (no optimizing compiler): we transpile the Gui
* ESM packages with Next's built-in `transpilePackages` and let `GuiProvider`
* inject CSS at runtime. Gui is designed to work this way — the compiler is an
* optimization, not a requirement.
*
* (The original reason to avoid `@hanzogui/next-plugin` no longer holds: the loader
* it depends on was unpublished at 7.3.0, but 8.x renamed it to `@hanzogui/loader`
* and both now ship. Adopting the compiler is therefore a live option — as an
* optimization to measure, not a correctness fix, so it is deliberately not bundled
* into the 8.x convergence.)
*
* `react-native` is aliased to `react-native-web` for the browser.
*
@@ -46,6 +50,9 @@ function guiPackages() {
return ['@hanzo/gui', '@hanzo/iam-js-sdk', '@hanzo/dash', '@hanzo/data', '@hanzo/canvas', '@hanzo/finance-ui', '@hanzo/usage', '@hanzo/ui', 'react-native-web', ...scoped]
}
/** A `@hanzogui/<pkg>/<subpath>/index.{js,cjs}` metro-compat shim (see `webpack()`). */
const GUI_SUBPATH_SHIM = /\/@hanzogui\/([^/]+)\/([^/]+)\/index\.c?js$/
/**
* Same-origin `/v1/*` — ZERO client-visible prefix (the CTO contract: "no prefix
* before /v1/ in any API call"). The browser ALWAYS calls its OWN origin at a clean
@@ -97,10 +104,10 @@ const AI_V1_HEADS = ['models', 'chat', 'embeddings', 'rerank', 'audio', 'images'
// (`providers/toggle`, `providers/primary`) both match the `/:path*` rewrite below,
// which is method-agnostic (Next matches on the URL), so POST is covered without a
// second entry. Keep this in sync with `admin-aggregate.ts` ADMIN_AGGREGATE_HEADS.
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'spend-caps', 'block-storage']
const ADMIN_V1_HEADS = ['overview', 'usage', 'orgs', 'audit', 'products', 'finance', 'compute', 'o11y', 'providers', 'customers', 'revenue', 'analytics', 'enablement', 'grants', 'referrals', 'affiliates', 'authors', 'treasury', 'services', 'promos', 'caps', 'volumes']
/**
* DEV-ONLY: proxy the client's direct-cloud `/v1/{iam,o11y}/*` calls (get-account,
* annotation-queues/users) to a real cloud backend so `npm run dev` renders the
* reviews/users) to a real cloud backend so `npm run dev` renders the
* authenticated shell locally. Enabled ONLY when `DEV_CLOUD_ORIGIN` is set (never in
* the built image), so production is unchanged — there the console host's edge routes
* `/v1` to the console, whose `/v1` catch-all forwards to cloud-api. The request cookie
@@ -206,7 +213,40 @@ const nextConfig = {
experimental: {
esmExternals: true,
},
webpack(config) {
webpack(config, { webpack }) {
// `@hanzogui/*` 8.x ships legacy metro-compat subpath DIRECTORIES (`config/v5/`,
// `themes/v5/`, `shorthands/v5/`, …) beside the `exports` map that already names
// the real entry. Each holds a CommonJS `index.js` — inside a `"type": "module"`
// package. Whatever resolves the directory therefore parses that file as ESM: the
// `export *` chain goes opaque ("'defaultConfig' is not exported from
// '@hanzogui/config/v5'") and its bare `require('../dist/cjs/v5.cjs')` survives
// into the server chunk, where it MODULE_NOT_FOUNDs at prerender (the require is
// relative to `.next/server/chunks/`, not to the package).
//
// So redirect any such shim to the ESM build sitting beside it. Pattern-based, on
// the RESOLVED file, so it holds however the request got there — and costs nothing
// the day the shims stop shipping.
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(GUI_SUBPATH_SHIM, (data) => {
const resource = data.createData?.resource
if (!resource) return
const shim = GUI_SUBPATH_SHIM.exec(resource)
if (!shim) return
const esm = `${resource.slice(0, shim.index)}/@hanzogui/${shim[1]}/dist/esm/${shim[2]}.mjs`
if (!existsSync(esm)) return
// The module's CONTEXT must move with it, or its own relative imports
// (`./v5-base.mjs`) keep resolving against the shim directory.
data.createData.resource = esm
data.createData.userRequest = esm
data.createData.context = dirname(esm)
data.context = dirname(esm)
}),
)
// @hanzo/ui is consumed from SOURCE via a workspace link. Keep the symlinked path
// so its own imports (@hanzo/gui, @hanzogui/*) walk up into the CONSOLE's
// node_modules — one Tamagui instance, as the tsconfig `paths` already pin for
// types. Resolving the realpath would load a second copy and break theme context.
config.resolve.symlinks = false
config.resolve.alias = {
...config.resolve.alias,
'react-native$': 'react-native-web',
+1463 -3986
View File
File diff suppressed because it is too large Load Diff
+25 -23
View File
@@ -1,8 +1,9 @@
{
"name": "@hanzo/console",
"version": "8.5.31",
"version": "8.5.62",
"packageManager": "pnpm@11.17.0",
"private": true,
"license": "BSD-3-Clause",
"license": "MIT OR Apache-2.0",
"author": "Hanzo AI <dev@hanzo.ai>",
"description": "Hanzo Cloud Console — unified admin console for Hanzo Cloud and all cloud products.",
"scripts": {
@@ -13,25 +14,27 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed",
"postinstall": "patch-package"
"e2e:headed": "playwright test --headed"
},
"dependencies": {
"@hanzo/brand": "^1.4.0",
"@hanzo/canvas": "^0.1.0",
"@hanzo/dash": "0.3.0",
"@hanzo/data": "^1.2.0",
"@hanzo/event": "^0.3.1",
"@hanzo/finance-ui": "0.1.1",
"@hanzo/gui": "7.3.0",
"@hanzo/iam": "^0.13.6",
"@hanzo/logo": "^1.0.13",
"@hanzo/ui": "^8.0.6",
"@hanzo/brand": "^1.4.5",
"@hanzo/canvas": "^0.2.1",
"@hanzo/dash": "^0.3.0",
"@hanzo/data": "^1.2.2",
"@hanzo/design": "^0.4.6",
"@hanzo/event": "^0.3.8",
"@hanzo/finance-ui": "~0.1.1",
"@hanzo/gui": "^8.0.0",
"@hanzo/iam": "^0.21.6",
"@hanzo/logo": "^1.0.14",
"@hanzo/ui": "^8.0.56",
"@hanzo/usage": "^0.1.6",
"@hanzogui/config": "7.3.0",
"@hanzogui/core": "7.3.0",
"@hanzogui/lucide-icons-2": "7.3.0",
"@hanzogui/next-theme": "7.3.0",
"@hanzogui/config": "^8.0.0",
"@hanzogui/core": "^8.0.0",
"@hanzogui/lucide-icons-2": "^8.0.0",
"@hanzogui/next-theme": "^8.0.0",
"@hanzogui/telemetry": "^8.0.0",
"@hanzogui/shell": "^8.1.1",
"@lexical/html": "0.46.0",
"@lexical/link": "0.46.0",
"@lexical/list": "0.46.0",
@@ -51,9 +54,9 @@
"qrcode.react": "4.2.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-native-svg": "15.15.5",
"react-native-web": "0.21.2",
"superjson": "2.2.2",
"@hanzogui/shell": "^7.6.3"
"superjson": "2.2.2"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
@@ -61,8 +64,7 @@
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"react-native": "0.83.9",
"typescript": "5.9.3",
"vitest": "3.2.4",
"patch-package": "^8.0.0"
"typescript": "^5.9.3",
"vitest": "3.2.4"
}
}
-132
View File
@@ -1,132 +0,0 @@
diff --git a/node_modules/@hanzo/iam/dist/browser.cjs b/node_modules/@hanzo/iam/dist/browser.cjs
index fe3a04e..41c367e 100644
--- a/node_modules/@hanzo/iam/dist/browser.cjs
+++ b/node_modules/@hanzo/iam/dist/browser.cjs
@@ -785,6 +785,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/browser.js b/node_modules/@hanzo/iam/dist/browser.js
index 4228603..1b9db27 100644
--- a/node_modules/@hanzo/iam/dist/browser.js
+++ b/node_modules/@hanzo/iam/dist/browser.js
@@ -783,6 +783,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/index.cjs b/node_modules/@hanzo/iam/dist/index.cjs
index d49c5d4..81cfb85 100644
--- a/node_modules/@hanzo/iam/dist/index.cjs
+++ b/node_modules/@hanzo/iam/dist/index.cjs
@@ -1172,6 +1172,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/index.js b/node_modules/@hanzo/iam/dist/index.js
index 48c5699..7a85d23 100644
--- a/node_modules/@hanzo/iam/dist/index.js
+++ b/node_modules/@hanzo/iam/dist/index.js
@@ -1170,6 +1170,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/react.cjs b/node_modules/@hanzo/iam/dist/react.cjs
index 8642b04..66da7d3 100644
--- a/node_modules/@hanzo/iam/dist/react.cjs
+++ b/node_modules/@hanzo/iam/dist/react.cjs
@@ -719,6 +719,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
diff --git a/node_modules/@hanzo/iam/dist/react.js b/node_modules/@hanzo/iam/dist/react.js
index 8f4927a..81bef42 100644
--- a/node_modules/@hanzo/iam/dist/react.js
+++ b/node_modules/@hanzo/iam/dist/react.js
@@ -717,6 +717,17 @@ var IAM = class {
if (tokens.expires_in) {
const expiresAt = Date.now() + tokens.expires_in * 1e3;
this.storage.setItem(KEY_EXPIRES_AT, String(expiresAt));
+ } else {
+ try {
+ const _p = tokens.access_token.split(".");
+ if (_p.length === 3) {
+ let _b = _p[1].replace(/-/g, "+").replace(/_/g, "/");
+ while (_b.length % 4) _b += "=";
+ const _j = typeof atob === "function" ? atob(_b) : Buffer.from(_b, "base64").toString("binary");
+ const _e = JSON.parse(_j).exp;
+ if (typeof _e === "number") this.storage.setItem(KEY_EXPIRES_AT, String(_e * 1e3));
+ }
+ } catch (_) {}
}
}
/** Get the stored access token (may be expired). */
+9243
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
# Install scripts run only for packages listed here.
allowBuilds:
esbuild: true
sharp: true
# Our own scopes skip pnpm's new-release quarantine. Scope glob, not a version
# pin: pnpm rewrites version entries on every bump and breaks --frozen-lockfile.
minimumReleaseAgeExclude:
- '@hanzo/*'
- '@hanzogui/*'
+2 -2
View File
@@ -195,8 +195,8 @@ try {
stdio: 'inherit',
// CONSOLE_EMBED gates the server-side build transforms; NEXT_PUBLIC_CONSOLE_EMBED
// is inlined into the CLIENT bundle so runtime code (lib/embed.ts → IS_EMBED) can
// skip the BFF-only session probes (/auth/refresh|session, /billing welcome) that
// don't exist in this static, server-less deployment.
// skip the BFF-only session probes (/auth/refresh|session) that don't exist in
// this static, server-less deployment.
env: { ...process.env, CONSOLE_EMBED: '1', NEXT_PUBLIC_CONSOLE_EMBED: '1' },
})
+48
View File
@@ -0,0 +1,48 @@
/**
* Asks the RENDERER which props @hanzo/gui 8 actually honors.
*
* gui accepts any prop and drops the ones it does not know, so a gui-7 `tag="a"`
* type-checks, builds, and ships a <div>: the link is inert and nothing anywhere says
* so. A green build cannot answer this; only the rendered markup can. Every rule in
* `src/lib/gui8-props.ts` was verified here before it was written down.
*
* gui injects its stylesheet as a leading <style>, so read the host element from the
* marked child — never from the first tag in the string.
*
* node scripts/gui-prop-probe.mjs (slow: it resolves ~184 unbundled ESM packages)
*/
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { defaultConfig } from '@hanzogui/config/v5'
import { XStack, Text, createGui, GuiProvider } from '@hanzo/gui'
const config = createGui(defaultConfig)
const render = (el) =>
renderToStaticMarkup(React.createElement(GuiProvider, { config, defaultTheme: 'dark' }, el))
/** The element carrying our marker id — not gui's injected <style>. */
const marked = (markup) => markup.match(/<([a-z]+)[^>]*\bid="probe"[^>]*>/)?.[0] ?? ''
const hostOf = (markup) => marked(markup).match(/^<([a-z]+)/)?.[1] ?? '(not found)'
const probe = (label, Comp, props) => {
const markup = render(React.createElement(Comp, { id: 'probe', ...props }, 'x'))
console.log(`${label.padEnd(30)} -> ${marked(markup) || '(not found)'}`)
return { host: hostOf(markup), markup }
}
console.log('--- host element: tag (gui 7) vs render (gui 8) ---')
const withTag = probe('tag="a"', XStack, { tag: 'a', href: 'https://hanzo.ai' })
const withRender = probe('render="a"', XStack, { render: 'a', href: 'https://hanzo.ai' })
console.log('\n--- style props that silently drop or mis-unit ---')
probe('lineHeight={1.1} (prop)', Text, { lineHeight: 1.1 })
probe('style lineHeight: 1.1 (ratio)', Text, { style: { lineHeight: 1.1 } })
probe("style lineHeight: '1.1' (string)", Text, { style: { lineHeight: '1.1' } })
probe('letterSpacing="-0.02em"', Text, { letterSpacing: '-0.02em' })
probe('animation="quick"', XStack, { animation: 'quick' })
probe('$sm={{...}}', XStack, { $sm: { bg: '$red10' } })
probe('$gtSm={{...}}', XStack, { $gtSm: { bg: '$red10' } })
console.log()
console.log(withTag.host === 'a' ? 'tag WORKS' : 'tag IS SILENTLY DROPPED')
console.log(withRender.host === 'a' ? 'render WORKS' : 'render IS SILENTLY DROPPED')
+84
View File
@@ -0,0 +1,84 @@
'use client'
/**
* The account control — WHO you are: identity, your team, your personal
* settings, what you have left to spend, and the way out. ONE control, at the
* foot of the rail.
*
* It deliberately does NOT switch tenant. Org and project are one question —
* WHERE you are — and they are answered together by `ContextSwitcher` at the
* top-left, beside the tenant's own mark. Handing this menu an `orgState` too
* would put the org in two corners again, which is the exact confusion the
* condensed switcher removes. The cross-tenant reach, the admin-gated org list
* and the single `org-scope.switchOrg` money seam all moved there intact; there
* is still exactly one org switch in the app.
*
* It is `@hanzo/iam`'s `UserMenu`, the same component hanzo.chat mounts, so the
* identity and the behaviour (click-away, Escape, close-before-navigate, never a
* raw uuid) are shared rather than rebuilt. This file is the ADAPTER —
* everything the console knows that the SDK does not:
*
* - THEME. The console themes through `@hanzogui/next-theme` (which drives the
* Gui tree). That is adapted into the menu's shape rather than mounting IAM's
* own theme hook beside it — one theme system, not two.
*
* - BRAND. The strip at the foot wears THIS host's brand. Passing nothing would
* paint a Hanzo mark on a Lux or Zoo console.
*/
import { useMemo } from 'react'
import { UserMenu, type UserTheme } from '@hanzo/iam/react'
import { useThemeSetting } from '@hanzogui/next-theme'
import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
import { useCloudBalance, spendableCents } from '~/lib/billing/live-balance'
export function AccountMenu() {
const { account, signOut } = useSession()
const { balance } = useCloudBalance()
const { current, resolvedTheme, set } = useThemeSetting()
// `system` is a real choice, and the console's provider already understands it.
const theme: UserTheme = useMemo(
() => ({
mode: (current === 'light' || current === 'dark' ? current : 'system') as UserTheme['mode'],
resolved: (resolvedTheme ?? current) === 'light' ? 'light' : 'dark',
setMode: (mode) => set(mode),
}),
[current, resolvedTheme, set],
)
if (!account) return null
const cents = spendableCents(balance)
const name = account.displayName?.trim() || account.name
return (
<UserMenu
align="up"
identity={{
name,
email: account.email ?? null,
initials: (name || '?').slice(0, 1).toUpperCase(),
avatarUrl: account.avatar || null,
}}
isAuthenticated
isLoading={false}
onSignOut={() => void signOut()}
theme={theme}
settingsUrl="/profile"
usageUrl="/billing"
usageLabel="Billing & usage"
// Only shown when the backend actually reported a balance — never a fabricated $0.
balance={cents === null ? undefined : { amountUsd: cents / 100, topUpUrl: config.payUrl }}
items={[
// Your people, beside your own settings — the other half of "who am I".
// Choosing a DIFFERENT tenant is a different question and lives in the
// top-left context switcher, so this menu never re-scopes the console.
{ label: 'Members', href: '/team' },
{ label: 'Documentation', href: config.docsUrl, external: true, separatorBefore: true },
]}
brand={{ name: config.brandName }}
/>
)
}
+58 -13
View File
@@ -1,12 +1,12 @@
'use client'
/**
* All products — the directory where you curate your sidebar. Every Hanzo product
* is always available on demand; this panel lists the FULL catalog the viewer may
* see (brand-scoped, admin surfaces gated), grouped by category, each row with a
* PIN toggle that promotes/removes it from the sidebar's Pinned quick-access section
* (via `usePins`). Rendered in the shared DetailPane (opened from the sidebar's
* "All products" row).
* All products — the directory of every Hanzo app, where you OPEN one and where you
* curate your sidebar. Every product is always available on demand; this panel lists
* the FULL catalog the viewer may see (brand-scoped, admin surfaces gated), grouped
* by category. A row OPENS its app; the pin toggle beside it promotes/removes it from
* the sidebar's Pinned quick-access section (via `usePins`). Rendered in the shared
* DetailPane (opened from the sidebar's "All products" row).
*
* Honest by construction: pinning is instant + optimistic (persisted through the
* account preferences store — no async error state). Products with REAL org usage
@@ -15,16 +15,19 @@
* the badges/filter degrade away — never a fabricated "in use".
*/
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
import { Activity, Plus, Search, Star } from '@hanzogui/lucide-icons-2'
import { visibleCatalogByCategory, type CatalogEntry, type ProductIcon } from '~/lib/products/registry'
import { useAppsBeta } from '~/lib/products/beta'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { usePins, useProductColors } from '~/lib/products/pins'
import { openProduct } from '~/lib/products/open'
import { useDetailPane } from '~/components/DetailPane'
import { fetchUsageRecords } from '~/lib/api/aimetrics'
import { inUseProductIds } from '~/lib/products/product-usage'
import { asColor } from '~/components/ui/color'
import { EmptyState } from '~/components/ui/EmptyState'
import { EmptyState, asColor } from '@hanzo/ui/product'
/** The list narrowing controls at the top. */
type Filter = 'all' | 'inuse' | 'pinned'
@@ -41,23 +44,51 @@ function InUseBadge() {
)
}
/** One catalog row: icon · label/description (+ In-use badge) · pin toggle. */
/**
* One catalog row: icon · label/description (+ In-use badge) · pin toggle.
*
* The row itself OPENS the product. It used to be an inert `XStack` — a plain div
* with `cursor: auto`, no role and no handler — so the one place in the console that
* lists every app was a directory you could not walk: the only live control in the
* row was the pin. Opening is delegated to the shared `openProduct`, the ONE opener
* every other surface (sidebar, ⌘K, category page) already routes through.
*
* Pin stays a SEPARATE control on the same row, so curating never navigates and
* navigating never curates. It stops the press from bubbling into the row for the
* same reason.
*/
function ProductRow({
entry,
color,
pinned,
inUse,
onOpen,
onToggle,
}: {
entry: CatalogEntry
color: string
pinned: boolean
inUse: boolean
onOpen: () => void
onToggle: () => void
}) {
const Icon = entry.icon
return (
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" minH={44} hoverStyle={{ bg: '$color2' }}>
<XStack
role="button"
tabIndex={0}
onPress={onOpen}
cursor="pointer"
items="center"
gap="$3"
py="$2"
px="$2"
rounded="$3"
minH={44}
hoverStyle={{ bg: '$color2' }}
focusStyle={{ bg: '$color2' }}
aria-label={`Open ${entry.label}`}
>
<YStack width={32} height={32} rounded="$3" bg="$color3" items="center" justify="center">
<Icon size={16} color={asColor(color)} />
</YStack>
@@ -73,7 +104,10 @@ function ProductRow({
<Button
size="$2"
icon={pinned ? <Star size={15} /> : <Plus size={15} />}
onPress={onToggle}
onPress={(e?: { stopPropagation?: () => void }) => {
e?.stopPropagation?.()
onToggle()
}}
bg={pinned ? '$color5' : 'transparent'}
borderWidth={1}
borderColor="$borderColor"
@@ -121,6 +155,15 @@ export function AddProductPanel() {
const showAdmin = useIsSuperAdmin()
const { isPinned, toggle } = usePins()
const { colorOf } = useProductColors()
const router = useRouter()
const detail = useDetailPane()
// Opening an app takes you to it: navigate, then close the pane you launched from
// (it is a directory, not a destination — leaving it open would cover the page it
// just opened).
const openEntry = (entry: CatalogEntry) => {
openProduct(entry, (path) => router.push(path))
detail.close()
}
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<Filter>('all')
// Real org usage signal. `null` = not (yet) known; a Set (even empty) = a real
@@ -147,7 +190,8 @@ export function AddProductPanel() {
// Source = the FULL catalog the viewer may see (ungated → both pinned and unpinned
// appear), grouped by category.
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null), [showAdmin])
const showBeta = useAppsBeta(showAdmin)
const groups = useMemo(() => visibleCatalogByCategory(showAdmin, null, showBeta), [showAdmin, showBeta])
// Literal, case-insensitive substring match over label/description/id — NOT a
// compiled RegExp of user input.
@@ -226,7 +270,7 @@ export function AddProductPanel() {
) : (
shown.map((group) => (
<YStack key={group.category} gap="$1">
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase" px="$2">
<Text fontSize="$1" color="$color10" fontWeight="500" px="$2">
{group.category}
</Text>
{group.entries.map((entry) => (
@@ -236,6 +280,7 @@ export function AddProductPanel() {
color={colorOf(entry.id)}
pinned={isPinned(entry.id)}
inUse={inUse?.has(entry.id) ?? false}
onOpen={() => openEntry(entry)}
onToggle={() => toggle(entry.id)}
/>
))}
+55 -15
View File
@@ -1,27 +1,67 @@
'use client'
/**
* Bridges the console session + App-Router navigation into the shared analytics
* client (`@hanzo/event`). Rendered once, inside both `SessionProvider` and
* `AnalyticsProvider` (see `Provider.tsx`), it renders nothing.
* The console's telemetry surface — the ONE Hanzo telemetry provider, plus the one
* thing it deliberately leaves to the app.
*
* - `usePageview` emits a pageview on every path change (the provider fires the
* FIRST pageview itself, so this only covers subsequent client navigations).
* - `identify` binds the person to the STABLE `owner/name` actor id — the same id
* the API client already uses (`setCurrentActor`), never the email — once the
* session resolves. The org tenant is stamped server-side from the session, so
* we send the user id only. Anonymous placeholder sessions are skipped.
* `TelemetrySurface` mounts `@hanzogui/telemetry`, which owns the whole plane for
* this subtree: pageviews (including SPA route changes), `window.onerror` +
* `unhandledrejection`, React render errors (its internal boundary REPORTS and
* re-throws, so the app's own error UI still decides what the user sees), lazily
* imported interaction capture, and DNT/GPC consent. It replaces the hand-rolled
* `<AnalyticsProvider>` + bridge + boundary combo; it mounts @hanzo/event's
* `AnalyticsProvider` internally with its own client, so every existing
* `useAnalytics()` call site keeps working against that ONE client and one stream.
* `product="console"` is all the configuration there is — @hanzo/event's DSN
* registry resolves the hanzo-console Sentry project from it, so the error plane
* needs no `dsn` prop and no env var.
*
* It reads `usePathname()` ITSELF rather than taking a `path` prop from `Provider`:
* `Provider` memoizes its tree on `children`, so a path read up there would be
* baked into the cached element and go stale on the first client navigation.
*
* `AnalyticsBridge` is the one thing TelemetryProvider does NOT do — `identify`.
* It binds the person to the STABLE `owner/name` actor id (the same id the API
* client uses via `setCurrentActor`), never the email, once the session resolves.
* The org tenant is stamped server-side from the session, so we send the user id
* only, and anonymous placeholder sessions are skipped. It renders nothing and
* emits NO pageview — the provider owns those, and a second emitter would
* double-count every route.
*/
import { useEffect, useRef } from 'react'
import { useEffect, useRef, type ReactNode } from 'react'
import { usePathname } from 'next/navigation'
import { useAnalytics, usePageview } from '@hanzo/event/react'
import { TelemetryProvider, useTelemetry } from '@hanzogui/telemetry'
import { iamAccessToken } from '~/lib/auth/iam'
import { useSession } from '~/lib/auth/session'
import { type Account } from '~/lib/api/types'
/** The user attributes worth carrying alongside the id, from the IAM claims the
* session already decoded. A key is OMITTED rather than sent undefined, so an
* absent claim never overwrites a trait a prior identify established. */
export function identityTraits(account: Account): Record<string, unknown> {
const traits: Record<string, unknown> = {}
if (account.email) traits.email = account.email
const name = account.displayName ?? account.name
if (name) traits.name = name
return traits
}
export function TelemetrySurface({ children }: { children: ReactNode }) {
const path = usePathname()
// @hanzo/iam (PKCE) is the console's ONE credential and it is a BEARER — the same
// token `lib/api/client.ts` puts on every call — so telemetry authenticates the
// same way rather than relying on a cookie the ingest host would never receive.
return (
<TelemetryProvider product="console" path={path} getToken={iamAccessToken}>
{children}
</TelemetryProvider>
)
}
export function AnalyticsBridge() {
const analytics = useAnalytics()
const telemetry = useTelemetry()
const { account } = useSession()
usePageview(usePathname())
const identified = useRef('')
useEffect(() => {
@@ -29,8 +69,8 @@ export function AnalyticsBridge() {
const personId = `${account.owner}/${account.name}`
if (identified.current === personId) return
identified.current = personId
analytics.identify(personId)
}, [account, analytics])
telemetry.identify(personId)
}, [account, telemetry])
return null
}
+24 -6
View File
@@ -22,12 +22,13 @@ import { useIam } from '@hanzo/iam/react'
import { Button, Text, YStack } from '@hanzo/gui'
import { Loader } from '~/components/ui/Loader'
import { takeReturnTo } from '~/lib/auth/iam'
import { takeReturnTo, startReauth } from '~/lib/auth/iam'
import { classifyCallback, type CallbackVerdict } from '~/lib/auth/callback-error'
export function AuthCallback() {
const router = useRouter()
const { handleCallback } = useIam()
const [error, setError] = useState<string | null>(null)
const [verdict, setVerdict] = useState<CallbackVerdict | null>(null)
// The OAuth `code` is SINGLE-USE and the SDK removes the PKCE verifier BEFORE the
// token fetch, so the exchange must fire EXACTLY ONCE. Without this guard, React
// StrictMode's double-invoke (or any `handleCallback` identity change re-running the
@@ -47,20 +48,37 @@ export function AuthCallback() {
window.location.assign(takeReturnTo())
})
.catch(() => {
if (!cancelled) setError('Sign-in failed.')
if (cancelled) return
// The SDK is the AUTHORITY on whether a callback is a sign-in — it validates
// `state` before honouring an error branch, so an attacker-supplied
// /callback?error=… cannot mint a session. It reports every refusal the same
// way, though, so this screen used to say "Sign-in failed." to someone who had
// merely cancelled a consent screen. Read the code for WORDING only; the
// decision was already made above.
setVerdict(
classifyCallback(typeof window === 'undefined' ? '' : window.location.search) ?? {
kind: 'failed',
message: 'Sign-in failed.',
},
)
})
return () => {
cancelled = true
}
}, [handleCallback])
if (error) {
if (verdict) {
// A refusal is not always a fault. Only a genuine failure is worded as one, and
// the two benign outcomes lead with the action that actually resolves them.
const retry = verdict.kind === 'failed' ? 'Back to sign in' : 'Sign in'
return (
<YStack flex={1} minH="100vh" items="center" justify="center" gap="$3">
<Text color="$color12" fontWeight="600">
{error}
{verdict.message}
</Text>
<Button onPress={() => router.replace('/signin')}>Back to sign in</Button>
<Button onPress={() => (verdict.kind === 'failed' ? router.replace('/signin') : startReauth())}>
{retry}
</Button>
</YStack>
)
}
+136 -23
View File
@@ -15,14 +15,20 @@
* settings, switch organization, ask AI / search docs, sign out — ranked by the
* same query, so ⌘K is ONE surface for "go somewhere" and "do something".
*
* PINS are first-class here, not just in the sidebar. A user's pinned products
* lead the results (their own pin order, via `pinnedFirst`), and every result
* carries a pin at its right edge — quiet until you reach the row, lit while it is
* pinned. `⌥↵` pins the selected result WITHOUT leaving the palette, so curating
* what you keep at hand is the same gesture as finding it: type, ⌥↵, keep going.
*
* Everything composes existing pieces: the catalog registry (`searchCatalog` +
* `openProduct`), the AI client (`AiApi`), the chrome hooks (theme/launcher/
* session/org-scope), and the honest backend-state mapper. Nothing is fabricated —
* `openProduct`), the AI client (`AiApi`), the chrome hooks (theme/session/
* org-scope), and the honest backend-state mapper. Nothing is fabricated —
* AI/RAG failures degrade to a truthful state card.
*
* Keyboard is handled on `window`: ⌘K toggles from anywhere; while open, ↑/↓ move
* the selection (over actions then products), ↵ activates, Esc closes. The header
* search box and Apps buttons open it; type `>` for AI, `?` for docs.
* the selection (over actions then products), ↵ activates, ⌥↵ pins/unpins, Esc
* closes. The header search box and Apps buttons open it; `>` for AI, `?` for docs.
*/
import {
createContext,
@@ -57,6 +63,7 @@ import {
Lock,
LogOut,
Moon,
Pin,
Search,
SlidersHorizontal,
Sparkles,
@@ -66,16 +73,17 @@ import {
import { AiApi, IamAdminApi, type Organization } from '~/lib/api'
import { findEntry, type CatalogEntry } from '~/lib/products/registry'
import { commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
import { assistantState, commandBarSystemPrompt, hanzoAssistantSystemPrompt } from '~/lib/assistant'
import { searchDestinations, type Destination } from '~/lib/products/search'
import { useProductColors } from '~/lib/products/pins'
import { asColor } from '~/components/ui/color'
import { DEFAULT_GROUP_LABEL, pinnedFirst } from '~/lib/products/pins-core'
import { usePins, useProductColors } from '~/lib/products/pins'
import { useAppsBeta } from '~/lib/products/beta'
import { ProductIcon } from '~/components/ui/ProductIcon'
import { openProduct } from '~/lib/products/open'
import { currentOrg, switchOrg } from '~/lib/org-scope'
import { useSession } from '~/lib/auth/session'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { BackendStateCard, asColor, type BackendState } from '@hanzo/ui/product'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
@@ -138,7 +146,7 @@ function Answer({ text }: { text: string }) {
</YStack>
{urls.length > 0 ? (
<YStack gap="$1" borderTopWidth={1} borderColor="$borderColor" pt="$2">
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
<Text fontSize="$1" color="$color10" fontWeight="500">
Links
</Text>
{urls.map((u) => (
@@ -152,20 +160,60 @@ function Answer({ text }: { text: string }) {
)
}
/**
* The pin at a result row's right edge. Quiet by construction: invisible until the
* row is reached (hovered via `.hz-pin` + `.hz-row-pin:hover`, or selected by the
* keyboard), and steady once the product is pinned — so the control appears where
* the eye already is and the lit ones read as state, not decoration.
*/
function PinToggle({ label, pinned, active, onPress }: { label: string; pinned: boolean; active: boolean; onPress: () => void }) {
return (
<XStack
className={pinned || active ? undefined : 'hz-pin'}
onPress={(e: { stopPropagation?: () => void }) => {
// The row itself navigates; pinning must not also open the product.
e?.stopPropagation?.()
onPress()
}}
cursor="pointer"
items="center"
justify="center"
width={26}
height={26}
rounded="$2"
// NO inline `opacity` prop: Gui compiles one to an atomic rule it injects at
// `:root ._ops-…` (specificity 0,2,0), which would outrank the `.hz-pin`
// utility and paint the resting pin at full strength. Resting visibility is
// the CSS utility's job alone; presence/absence of the class is the switch.
hoverStyle={{ bg: '$color6' }}
role="button"
aria-pressed={pinned}
aria-label={`${pinned ? 'Unpin' : 'Pin'} ${label}`}
>
<Pin size={13} color={pinned ? '$color12' : '$color10'} fill={pinned ? 'currentColor' : 'none'} />
</XStack>
)
}
function CatalogRow({
entry,
active,
color,
pinned,
onPin,
onPress,
}: {
entry: CatalogEntry
active: boolean
color?: string
pinned?: boolean
onPin?: () => void
onPress: () => void
}) {
const Icon = entry.icon
return (
<XStack
className="hz-row-pin"
onPress={onPress}
cursor="pointer"
items="center"
@@ -188,25 +236,45 @@ function CatalogRow({
</Text>
</YStack>
{entry.admin ? <Lock size={13} opacity={0.45} /> : null}
{onPin ? <PinToggle label={entry.label} pinned={pinned === true} active={active} onPress={onPin} /> : null}
<ArrowRight size={13} opacity={active ? 0.8 : 0.3} />
</XStack>
)
}
/** A ⌘K result — a product (via CatalogRow) or a deep sub-page jump. */
/**
* A ⌘K result — a product (via CatalogRow) or a deep sub-page jump.
*
* Only PRODUCTS carry a pin: the pin model keys on a product id, so pinning a
* sub-page would either silently pin its parent (a lie about what you clicked) or
* need a second pin model. One model, so sub-pages simply don't offer the control.
*/
function DestinationRow({
dest,
active,
colorOf,
pinned,
onPin,
onPress,
}: {
dest: Destination
active: boolean
colorOf: (id: string) => string
pinned?: boolean
onPin?: () => void
onPress: () => void
}) {
if (dest.kind === 'product')
return <CatalogRow entry={dest.entry} active={active} color={colorOf(dest.entry.id)} onPress={onPress} />
return (
<CatalogRow
entry={dest.entry}
active={active}
color={colorOf(dest.entry.id)}
pinned={pinned}
onPin={onPin}
onPress={onPress}
/>
)
const { entry, subpage } = dest
const Icon = subpage.icon ?? entry.icon
return (
@@ -279,7 +347,7 @@ function ActionRow({
/** A small uppercase section label inside the palette result list. */
function SectionLabel({ children }: { children: ReactNode }) {
return (
<Text px="$3" pt="$2" pb="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
<Text px="$3" pt="$2" pb="$1" fontSize="$1" color="$color10" fontWeight="500">
{children}
</Text>
)
@@ -297,7 +365,9 @@ function PaletteDialog({
const router = useRouter()
const { signOut } = useSession()
const showAdmin = useIsSuperAdmin()
const showBeta = useAppsBeta(showAdmin)
const { colorOf } = useProductColors()
const pins = usePins()
const { current, resolvedTheme, set: setTheme } = useThemeSetting()
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
const [query, setQuery] = useState(seed)
@@ -329,7 +399,7 @@ function PaletteDialog({
}, [open, showAdmin])
// Every command the palette can RUN (verbs + per-org switches). Composed from the
// same pieces the chrome uses (router, launcher, theme, session, org scope) — no
// same pieces the chrome uses (router, theme, session, org scope) — no
// dead entries: each `run` is wired.
const actions = useMemo<PaletteAction[]>(() => {
const cur = currentOrg()
@@ -359,11 +429,22 @@ function PaletteDialog({
// ⌘K is a DISCOVERY surface: it jumps to the WHOLE catalog (admin-gated only), NOT
// the org's entitled scope — entitlement governs the sidebar + product use, never
// what you can find/jump to. Admin-only operator surfaces stay gated by `showAdmin`.
//
// PINS ORDER THE DEFAULT VIEW; TYPING ORDERS ITSELF. With no query the list is the
// user's own — their pinned products lead, in their own pin order, exactly as the
// sidebar's Pinned section does. The moment they type, RELEVANCE decides and pins
// step aside.
//
// That split is not fussiness, it is a bug this cost: floating pins over a ranked
// query meant a barely-matching pinned product outranked an exact name match, and
// typing "billing" + ↵ opened Models. A search that ignores what you typed is not
// a search, so the ranked branch is left strictly alone.
const destResults = useMemo(() => {
if (mode !== 'catalog') return []
const found = searchDestinations(query, showAdmin, null)
return sub ? found.slice(0, 50) : found
}, [mode, query, sub, showAdmin])
const found = searchDestinations(query, showAdmin, null, showBeta)
if (sub) return found.slice(0, 50)
return pinnedFirst(found, (d) => (d.kind === 'product' ? d.entry.id : ''), pins.pinnedIds)
}, [mode, query, sub, showAdmin, pins.pinnedIds])
const matchedActions = useMemo(
() => (mode === 'catalog' && sub ? actions.filter((a) => actionMatches(a, sub.toLowerCase())) : []),
@@ -382,17 +463,22 @@ function PaletteDialog({
// Empty query is the Apps browse state: the same destinations, grouped for scan
// speed. Typing switches to one ranked list without opening a second overlay.
//
// Pinned products already lead `destResults`, so labelling them by "Pinned"
// rather than their category collects them into ONE leading section — the same
// section, in the same order, under the same word the sidebar uses.
const browseGroups = useMemo(() => {
if (mode !== 'catalog' || sub) return []
const groups: { category: string; rows: { dest: Destination; index: number }[] }[] = []
destResults.forEach((dest, index) => {
const category = dest.entry.category
const category =
dest.kind === 'product' && pins.isPinned(dest.entry.id) ? DEFAULT_GROUP_LABEL : dest.entry.category
const last = groups[groups.length - 1]
if (last?.category === category) last.rows.push({ dest, index })
else groups.push({ category, rows: [{ dest, index }] })
})
return groups
}, [mode, sub, destResults])
}, [mode, sub, destResults, pins])
// Seed the query each time the palette opens.
useEffect(() => {
@@ -423,6 +509,18 @@ function PaletteDialog({
[onOpenChange, router],
)
/**
* Pin/unpin a destination WITHOUT closing — curating is a repeated act, and an
* overlay that dismisses itself after each one would make pinning five products
* five round trips. A sub-page has no pin (see `DestinationRow`), so it no-ops.
*/
const togglePin = useCallback(
(dest: Destination) => {
if (dest.kind === 'product') pins.toggle(dest.entry.id)
},
[pins],
)
const submit = useCallback(async () => {
if (!sub) return
setRun({ status: 'loading' })
@@ -445,7 +543,7 @@ function PaletteDialog({
setRun({ status: 'text', text: ans })
}
} catch (e) {
setRun({ status: 'error', state: classifyBackend(e) })
setRun({ status: 'error', state: assistantState(e) })
}
}, [mode, sub, showAdmin])
@@ -467,10 +565,16 @@ function PaletteDialog({
} else if (e.key === 'Enter') {
e.preventDefault()
const it = items[sel]
if (it) {
if (it.kind === 'action') it.action.run()
else activateDest(it.dest)
if (!it) return
// ⌥↵ is the secondary verb on the selection: keep this, stay here.
// ↵ is the primary: go there. Same key, one modifier apart, so the
// pair is learnable from the legend without a second shortcut to know.
if (e.altKey) {
if (it.kind === 'dest') togglePin(it.dest)
return
}
if (it.kind === 'action') it.action.run()
else activateDest(it.dest)
}
} else if (e.key === 'Enter') {
e.preventDefault()
@@ -481,7 +585,7 @@ function PaletteDialog({
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, mode, items, sel, run, submit, activate, activateDest, onOpenChange])
}, [open, mode, items, sel, run, submit, activate, activateDest, togglePin, onOpenChange])
// Keep the ↑/↓-selected row visible: as selection moves past the fold, scroll
// the active row into view (the list can hold 50 results — well beyond 560px).
@@ -536,6 +640,10 @@ function PaletteDialog({
placeholder={placeholder}
fontSize="$4"
color="$color12"
// `unstyled` still leaves Gui's own 3px base radius, which belongs to
// no scale and is visible as a faint rounding inside the palette's
// own 12px panel. A field that fills its container is flat.
rounded="$0"
autoCapitalize="none"
autoCorrect={false}
/>
@@ -569,6 +677,8 @@ function PaletteDialog({
dest={dest}
active={index === sel}
colorOf={colorOf}
pinned={dest.kind === 'product' && pins.isPinned(dest.entry.id)}
onPin={dest.kind === 'product' ? () => togglePin(dest) : undefined}
onPress={() => activateDest(dest)}
/>
</YStack>
@@ -592,6 +702,8 @@ function PaletteDialog({
dest={dest}
active={i === sel}
colorOf={colorOf}
pinned={dest.kind === 'product' && pins.isPinned(dest.entry.id)}
onPin={dest.kind === 'product' ? () => togglePin(dest) : undefined}
onPress={() => activateDest(dest)}
/>
)
@@ -641,6 +753,7 @@ function PaletteDialog({
>
<Legend keys="↑↓" label="navigate" />
<Legend keys="↵" label="open" />
<Legend keys="⌥↵" label="pin" />
<Legend keys=">" label="AI" />
<Legend keys="?" label="docs" />
<XStack flex={1} />
+216
View File
@@ -0,0 +1,216 @@
'use client'
/**
* Context switcher WHERE you are: the organization and the project, in ONE
* control, at the TOP-LEFT where the tenant's mark already sits.
*
* The console used to answer "who and where am I" from three different corners:
* the org at the top of the rail, the account (which also switched org) at its
* foot, and the project chip in the top-right beside the network. Org and
* project are one question which tenant, and which slice of it so they are
* one control, and it sits with the org mark that already anchors the top-left.
*
* The ACCOUNT keeps the other question ("who am I": identity, team, personal
* settings, the way out) at the foot of the rail. The NETWORK stays its own
* control in the top-right, because it is a global MODE rather than a place
* and its tier dot is a destructive-environment guard, not decoration.
*
* There is still exactly ONE org switch. `switchOrg` is passed by reference from
* `~/lib/org-scope` (the seam that persists the scope and reloads so every
* module refetches under the new `X-Org-Id`, which is where tenant scoping and
* its billing attribution already live). This control does not mint a second
* one, add a header of its own, or make a billing call `org-state.test.ts`
* pins that identity. Cross-tenant reach is the SAME admin-gated, server-paged
* list the full-page picker uses; a regular user never fires it and sees only
* their own org.
*/
import { useCallback, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { ChevronsUpDown, FolderGit2, Plus } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { useOrgIdentity } from '~/components/ui/BrandLogo'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { IamAdminApi, type Organization } from '~/lib/api'
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
import { currentOrg, leaveOrg, switchOrg } from '~/lib/org-scope'
import { contextLabel, scopedOrgRow, titleCase } from '~/lib/account/org-state'
import { MenuRow } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
import { SearchInput } from '@hanzo/ui/product'
export function ContextSwitcher() {
const router = useRouter()
const org = useOrgIdentity()
const scoped = currentOrg()
const isSuperAdmin = useIsSuperAdmin()
const { scope, projects, loadingProjects, selectProject } = useScope()
const [open, setOpen] = useState(false)
const [orgs, setOrgs] = useState<Organization[] | null>(null)
const [query, setQuery] = useState('')
// IAM's display name when it has one; otherwise the slug, titled the same way
// `scopedOrgRow` titles it — one rule, so the trigger and the list agree.
const orgLabel = org.displayName || titleCase(org.name || scoped)
// The cross-tenant list is admin-gated at the proxy; a regular user would 403
// it, so they are never asked to — their own org is the honest answer. An admin
// searches the SERVER (the list is paged and far longer than one page), which is
// the only way to reach a tenant nobody is a member of.
const loadOrgs = useCallback(
async (q: string) => {
if (!isSuperAdmin) return setOrgs(scopedOrgRow(scoped) as Organization[])
const res = await IamAdminApi.organizations(orgQuery(0, q, ORG_PAGE_SIZE))
setOrgs(res.rows ?? [])
},
[isSuperAdmin, scoped],
)
const onOpenChange = useCallback(
(next: boolean) => {
setOpen(next)
if (next && orgs === null) void loadOrgs('')
},
[orgs, loadOrgs],
)
const search = useCallback(
(q: string) => {
setQuery(q)
void loadOrgs(q)
},
[loadOrgs],
)
const pick = useCallback(
(fn: () => void) => () => {
setOpen(false)
fn()
},
[],
)
const orgRows = useMemo(() => orgs ?? [], [orgs])
return (
<Popover open={open} onOpenChange={onOpenChange} placement="bottom-start">
<Popover.Trigger asChild>
<Button
size="$3"
chromeless
justify="flex-start"
px="$2"
data-testid="switcher-context"
iconAfter={<ChevronsUpDown size={13} opacity={0.6} />}
aria-label={`Organization and project — ${contextLabel(orgLabel, scope.project)}`}
>
{org.logo ? (
// The org's own logo IS the label — the uploaded mark takes the
// slot the name held, height-capped to the row so any aspect fits.
// A scoped project keeps its text beside it; the full text stays
// in the aria-label either way. Arbitrary tenant URL/data URL, so
// a raw <img> (next/image would need a per-tenant remote
// allow-list) — same call BrandLogo makes.
<XStack items="center" gap="$2" flex={1} minW={0}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={org.logo}
alt={orgLabel}
style={{ height: 22, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
/>
{scope.project ? (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
/ {scope.project}
</Text>
) : null}
</XStack>
) : (
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1} flex={1}>
{contextLabel(orgLabel, scope.project)}
</Text>
)}
</Button>
</Popover.Trigger>
<Popover.Content {...paper} p="$2" width={280}>
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Organization
</Text>
{/* Admins only: the cross-tenant list is server-paged and longer than
one page, so reaching a tenant nobody is a member of means SEARCHING
it, not scrolling. A regular user has one org and no field. */}
{isSuperAdmin ? (
<YStack px="$1" pb="$1">
{/* A search landmark names the control for assistive tech the shared
SearchInput has no accessible-name prop of its own. */}
<div role="search" aria-label="Find an organization">
<SearchInput value={query} onChange={search} placeholder="Find an organization" name="org" />
</div>
</YStack>
) : null}
<YStack role="radiogroup" aria-label="Organizations" gap="$0.5">
{orgRows.map((o) => (
<MenuRow
key={o.name}
label={o.displayName || o.name}
active={scoped === o.name}
onPress={pick(() => {
if (o.name !== scoped) switchOrg(o.name)
})}
/>
))}
</YStack>
{orgRows.length === 0 ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
{orgs === null ? 'Loading…' : 'No organization matches that.'}
</Text>
) : null}
<MenuRow label="All organizations" icon={<Plus size={14} />} onPress={pick(leaveOrg)} />
<XStack height={1} bg="$borderColor" my="$1" />
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Project
</Text>
<YStack role="radiogroup" aria-label="Projects" gap="$0.5">
{/* Org-level scope — no X-Project-Id sent. */}
<MenuRow
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={pick(() => selectProject(undefined))}
/>
{projects.map((p) => (
<MenuRow
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={pick(() => selectProject(p.name))}
/>
))}
</YStack>
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<MenuRow
label="New project"
icon={<FolderGit2 size={14} />}
onPress={pick(() => router.push('/projects'))}
/>
</YStack>
</Popover.Content>
</Popover>
)
}
+3 -2
View File
@@ -28,7 +28,8 @@ import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import { X } from '@hanzogui/lucide-icons-2'
import { SlideOver } from '~/components/ui/SlideOver'
import { asColor, type IconLike } from '~/components/ui/color'
import { Z } from '~/lib/z'
import { asColor, type IconLike } from '@hanzo/ui/product'
export type DetailDescriptor = {
/** Pane title (the item's name). */
@@ -82,7 +83,7 @@ export function DetailPane({ children }: { children: ReactNode }) {
side="right"
size={desc?.size ?? 460}
ariaLabel={typeof desc?.title === 'string' ? desc?.title : 'Details'}
zIndex={1100}
zIndex={Z.popover}
>
{/* Bare-mode layout: fixed header · scroll body · optional sticky footer. */}
<XStack items="center" gap="$2.5" px="$4" height={56} borderBottomWidth={1} borderColor="$borderColor">
+120 -59
View File
@@ -15,7 +15,23 @@
*
* Docking is a desktop concern (a phone has no room for a permanent column), so on
* `<lg` the assistant is ALWAYS the floating bubble/sheet regardless of the dock
* choice; `docked` only reserves the right column at `lg+`.
* choice. That fact the persisted CHOICE against a viewport that can honor it
* meets in exactly one place, `column`, and it is what every shape is chosen by.
*
* It is not a detail. The sheet is a MODAL dialog, so leaving it open behind a hidden
* column put a full-viewport dialog over the page: the column painted, and every click
* on it landed on the dialog instead an assistant you could read and could not type
* into. Hiding one of two open surfaces with CSS cannot fix that, because `display:
* none` on the dialog's own content does not make the dialog stop being modal. So only
* one is ever open, and `column` is the one fact that says which.
*
* The assistant has ONE entry point and it lives HERE: `AssistantFab`, a floating
* control fixed bottom-right over every dashboard page. It used to be two small
* buttons in the topbar (a brand-H and a mic), which put the assistant in a third
* place beside the search box, competing with the org/theme/alert chrome while
* this module owned every other shape it can take. Chat and voice are the same
* surface opened two ways, so they sit together, in the corner the assistant
* actually appears in.
*
* Every shape REUSES the one working chat surface (`ChatConversation` `AiApi.chat`
* the keyless `/ai` proxy /v1/chat/completions). Nothing about AI is rebuilt
@@ -25,27 +41,33 @@
*/
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack } from '@hanzo/gui'
import { PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
import { Button, Dialog, Text, VisuallyHidden, XStack, YStack, useMedia } from '@hanzo/gui'
import { Mic, PanelRight, PanelRightClose, Sparkles, X } from '@hanzogui/lucide-icons-2'
import { ChatConversation } from '~/components/products/chat/ChatConversation'
// NB: the old `BrandMark` bubble import was removed with the floating circle — the
// assistant now opens from the topbar's small brand-H + mic controls.
import { BrandMark } from '~/components/ui/BrandLogo'
import { usePreferences } from '~/lib/products/preferences'
import { voiceSupported } from '~/lib/voice'
import { Z } from '~/lib/z'
type FloatingChatApi = {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
/** True when the assistant is docked as a permanent right column (persisted). */
docked: boolean
/**
* True when the permanent right column IS the assistant right now: the dock choice,
* a viewport wide enough to hold it, and a page that is not already a composer.
* Every surface reads this rather than the raw choice, so exactly one composer is
* on screen at any width.
*/
column: boolean
setDocked: (v: boolean) => void
/**
* Open the assistant with a PRE-FILLED prompt (e.g. "Ask AI about this code" from the
* Code hub). The composer is seeded and focused; the user reviews and sends (never an
* auto-send no surprise billing), matching the suggested-prompt UX. Opens the floating
* sheet when floating; when docked, the permanent column receives the seed.
* auto-send no surprise billing), matching the suggested-prompt UX. The column takes
* the seed when it is the assistant; otherwise the sheet opens with it.
*/
ask: (prompt: string) => void
/** The current pending seed for the composer (consumed once by the active conversation). */
@@ -114,17 +136,14 @@ function ChatSheet({
onOpenChange,
onHistory,
onDock,
docked,
seed,
voiceSignal,
}: {
/** Open ONLY when the sheet is the assistant — never alongside the column. */
open: boolean
onOpenChange: (o: boolean) => void
onHistory: () => void
onDock: () => void
/** When docked (desktop), the permanent column is the surface so the floating
* sheet is suppressed at lg+ (it still serves phones, which have no column). */
docked: boolean
/** Pre-fill seed for the composer (from `useFloatingChat().ask`). */
seed?: string | null
/** Voice-start signal forwarded to the conversation ("talk to Hanzo"). */
@@ -136,12 +155,7 @@ function ChatSheet({
return (
<Dialog modal open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay
key="chat-overlay"
className="hz-scrim-in"
bg="rgba(0,0,0,0.5)"
$lg={{ bg: 'transparent', display: docked ? 'none' : undefined }}
/>
<Dialog.Overlay key="chat-overlay" className="hz-scrim-in" bg="rgba(0,0,0,0.5)" $lg={{ bg: 'transparent' }} />
<Dialog.Content
key="chat-content"
className="hz-paper hz-pop-in"
@@ -158,18 +172,8 @@ function ChatSheet({
width="100vw"
height="100dvh"
rounded="$0"
// Desktop (≥lg): a compact popover bottom-right, above the bubble. Docked →
// hidden at lg+ (the permanent right column replaces it).
$lg={{
t: 'auto',
l: 'auto',
b: 88,
r: 24,
width: 380,
height: 560,
rounded: '$6',
display: docked ? 'none' : undefined,
}}
// Desktop (≥lg): a compact popover bottom-right, above the bubble.
$lg={{ t: 'auto', l: 'auto', b: 88, r: 24, width: 380, height: 560, rounded: '$6' }}
>
<VisuallyHidden>
<Dialog.Title>Assistant</Dialog.Title>
@@ -204,6 +208,64 @@ function ChatSheet({
)
}
/**
* The floating assistant control bottom-right, on every dashboard page.
*
* Two ways into one surface, side by side: the brand mark opens the assistant (the
* docked right column on a laptop, the full sheet on a phone) and the mic opens it
* listening. The mic renders only where the browser can actually listen, so there is
* never a dead control.
*
* It sits ABOVE the Developers dock at `lg+` (that dock's collapsed bar is 44px and
* exists only there), and its caller suppresses it exactly where the assistant is
* already on screen: while the sheet is open, on the pages that ARE a composer
* (`/chat`, `/playground`), and while the assistant IS the column.
*/
function AssistantFab({ onOpen, onVoice }: { onOpen: () => void; onVoice: () => void }) {
const [voiceOk] = useState(() => voiceSupported())
return (
<XStack
testID="assistant-fab"
position="fixed"
r={20}
b={20}
$lg={{ b: 64 }}
items="center"
gap="$2"
style={{ zIndex: Z.raised }}
>
{voiceOk ? (
<Button
className="hz-paper"
size="$3"
circular
width={44}
height={44}
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
icon={<Mic size={18} />}
onPress={onVoice}
aria-label="Talk to Hanzo"
/>
) : null}
<Button
className="hz-paper"
size="$4"
circular
width={52}
height={52}
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
icon={<BrandMark size={22} />}
onPress={onOpen}
aria-label="Ask Hanzo"
/>
</XStack>
)
}
/**
* The DOCKED assistant a permanent right column. Rendered by `Dashboard`
* inside the layout's reserved right rail (lg+ only), so it reserves space beside
@@ -229,30 +291,36 @@ export function Chat({ children }: { children: ReactNode }) {
const { get, set } = usePreferences()
const docked = get<boolean>('chatDocked', false)
const setDocked = useCallback((v: boolean) => set('chatDocked', v), [set])
// The bubble is redundant — and OVERLAPS the composer's send control — on the
// pages that ARE a full chat/composer surface. Suppress it there (the assistant
// is still openable programmatically via `useFloatingChat`); every other page
// keeps the one-tap bubble.
const media = useMedia()
// The pages that ARE a full composer already show the assistant, so no other shape
// of it belongs on them — the bubble would overlap the page's own send control, and
// the column would be a second composer beside the first.
const onChatSurface =
pathname === '/chat' ||
pathname.startsWith('/chat/') ||
pathname === '/playground' ||
pathname.startsWith('/playground/')
// Every fact about which shape the assistant takes meets here and nowhere else: the
// persisted choice, a viewport wide enough to honor it, and whether this page is
// already a composer. A JS fact, not a media prop, because it decides what MOUNTS —
// a modal dialog that is merely hidden is still modal, and still eats every click.
const column = docked && media.lg && !onChatSurface
const [isOpen, setIsOpen] = useState(false)
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen((v) => !v), [])
// Seed the composer from anywhere (e.g. the Code hub's "Ask AI about this code").
// Open the floating sheet when floating; when docked, the permanent column is already
// on screen and receives the seed, so opening the (hidden) sheet is skipped.
// The column is already on screen and receives the seed; otherwise open the sheet —
// including on a phone whose owner once docked on a laptop, where the choice is
// remembered but no column exists to deliver the prompt.
const [seed, setSeed] = useState<string | null>(null)
const ask = useCallback(
(prompt: string) => {
setSeed(prompt)
if (!docked) setIsOpen(true)
if (!column) setIsOpen(true)
},
[docked],
[column],
)
const onHistory = useCallback(() => {
@@ -270,10 +338,9 @@ export function Chat({ children }: { children: ReactNode }) {
// conversation opens the mic on change.
const [voiceSignal, setVoiceSignal] = useState(0)
// The topbar brand-H entry: TOGGLE the assistant. Desktop → the docked right
// sidebar column; phones (no column) → the full sheet. Setting both in tandem is
// correct because at lg+ the sheet is suppressed while docked, and below lg the
// dock column is display:none — so one control opens the right surface per viewport.
// The topbar brand-H entry: TOGGLE the assistant. Desktop → the right column;
// phones (no column) → the full sheet. Both are set because `column` then admits
// exactly one of them per viewport.
const openChat = useCallback(() => {
const next = !docked
setDocked(next)
@@ -289,29 +356,23 @@ export function Chat({ children }: { children: ReactNode }) {
}, [setDocked])
return (
<Ctx.Provider value={{ isOpen, open, close, toggle, docked, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
<Ctx.Provider value={{ isOpen, open, close, toggle, column, setDocked, ask, seed, openChat, startVoice, voiceSignal }}>
{children}
{/* The bubble fixed bottom-right over every page. Hidden while open (the
sheet's own close is the single dismiss). Hidden on the chat/playground
surfaces (would overlap the page composer). At lg+ it is ALSO hidden when
docked (the permanent column is the surface); on phones it always shows,
since docking has no room there. */}
{/* NO floating bubble the big circle that covered page content is gone. The
assistant is opened from the topbar (the small brand-H "chat" control + the
"talk to Hanzo" mic), so the user's OWN brand leads the chrome and AI help is
one small press away. `open`/`toggle`/`ask` still drive it programmatically
(e.g. the Code hub's "Ask AI"). */}
{/* The assistant's ONE entry point bottom-right, over every page. Hidden
while the sheet is open (its own close is the single dismiss), on the pages
that ARE a composer, and while the column is the surface. `open`/`toggle`/
`ask` still drive the assistant programmatically (e.g. "Ask AI"). */}
{isOpen || onChatSurface || column ? null : <AssistantFab onOpen={openChat} onVoice={startVoice} />}
{/* The floating sheet. Suppressed on the full chat/playground surfaces (the page
IS the composer) and, at lg+, while docked (the right column is the surface);
on phones it's the assistant even when docked. */}
{/* The floating sheet the assistant wherever the column is not: every width on
a phone or tablet, and on a laptop until the user docks it. Never open at the
same time as the column, so there is one composer and it takes the click. */}
<ChatSheet
open={isOpen && !onChatSurface}
open={isOpen && !onChatSurface && !column}
onOpenChange={setIsOpen}
onHistory={onHistory}
onDock={dock}
docked={docked}
seed={seed}
voiceSignal={voiceSignal}
/>
+70 -4
View File
@@ -20,11 +20,27 @@ import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Building2, ArrowRight, Sparkles } from '@hanzogui/lucide-icons-2'
import { useSession } from '~/lib/auth/session'
import { slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
import { readOnboardRefusal, slugifyOrg, validateOrgName } from '~/lib/server/onboarding'
import { v1Url } from '~/lib/api/client'
import { FadeIn } from '~/components/ui/FadeIn'
import { FadeIn } from '@hanzo/ui/product'
type Phase = 'form' | 'done'
type Phase = 'form' | 'done' | 'exists'
// currentOwner asks the server which organization this account is in. IAM answers
// the legacy envelope ({status,msg,data}) on this address, so read through `data`
// and fall back to a bare body. Any failure returns null and the caller falls back
// to showing the server's own message — a recovery that guesses is worse than none.
async function currentOwner(): Promise<string | null> {
try {
const res = await fetch(v1Url('iam/account'), { credentials: 'include' })
if (!res.ok) return null
const body = (await res.json()) as { data?: { owner?: string }; owner?: string } | null
const owner = body?.data?.owner ?? body?.owner
return owner && owner !== 'admin' ? owner : null
} catch {
return null
}
}
export function OrgOnboarding() {
const { signIn, signOut } = useSession()
@@ -32,6 +48,9 @@ export function OrgOnboarding() {
const [busy, setBusy] = useState<false | 'create' | 'personal'>(false)
const [error, setError] = useState<string | null>(null)
const [phase, setPhase] = useState<Phase>('form')
// The org the SERVER says this account already admins, discovered only after a
// refused create. See the 409 branch in onboard().
const [existingOrg, setExistingOrg] = useState<string | null>(null)
const slug = slugifyOrg(name)
const named = validateOrgName(name)
@@ -55,7 +74,28 @@ export function OrgOnboarding() {
}
const json = (await res.json().catch(() => null)) as { org?: string; error?: string } | null
if (!res.ok || !json?.org) {
setError(json?.error || `Could not create the organization (HTTP ${res.status}).`)
// 409 = the FIRST-RUN GATE, not a name collision. Onboarding MOVES the caller
// into the org it founds, so founding a second one would orphan the org this
// account already admins — IAM refuses (provision.go: "you already have an
// organization"). This screen is only ever rendered when the client resolved
// an EMPTY owner, and an owner that is empty because a read failed looks
// exactly like a brand-new account. So the refusal is the first reliable
// signal that the session was wrong, and the only honest thing to do with it
// is recover: ask the server which org this account is actually in and offer
// the way in. Leaving the customer on a form that can never submit — with a
// message about organizations when they just typed a name — is how "it said
// the name was taken" happens.
const refusal = readOnboardRefusal(
res.status,
json?.error,
res.status === 409 ? await currentOwner() : null,
)
if (refusal.action === 'recover') {
setExistingOrg(refusal.org)
setPhase('exists')
} else {
setError(refusal.error)
}
setBusy(false)
return
}
@@ -69,6 +109,32 @@ export function OrgOnboarding() {
signIn()
}
if (phase === 'exists' && existingOrg) {
return (
<Center>
<FadeIn style={CENTER_STYLE}>
<Card p="$5" gap="$4" width={440} borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
<Building2 size={20} />
<YStack gap="$1" items="center">
<Text fontSize="$6" fontWeight="800">
You{'\u2019'}re already in {existingOrg}
</Text>
<Text fontSize="$3" color="$color11" text="center">
This account already belongs to an organization, so there was nothing to
create. Sign in again to continue there.
</Text>
</YStack>
{/* Explicit, never automatic. Re-authenticating on our own would loop
forever against whatever left the session without an owner. */}
<Button size="$3" onPress={() => signIn()}>
Continue to {existingOrg}
</Button>
</Card>
</FadeIn>
</Center>
)
}
if (phase === 'done') {
return (
<Center>
+1 -2
View File
@@ -27,10 +27,9 @@ import { useIsSuperAdmin } from '~/lib/auth/admin'
import { enterOrg } from '~/lib/org-scope'
import { IamAdminApi, type Organization } from '~/lib/api'
import { BrandMark } from '~/components/ui/BrandLogo'
import { EmptyState } from '~/components/ui/EmptyState'
import { FadeIn } from '~/components/ui/FadeIn'
import { OrgOnboarding } from '~/components/OrgOnboarding'
import { PAGE_SIZE, pickerView, type OrgCard, type PickerContext } from '~/components/org-picker/logic'
import { EmptyState, FadeIn } from '@hanzo/ui/product'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
-79
View File
@@ -1,79 +0,0 @@
'use client'
/**
* Org switcher the hoisted `@hanzo/ui` shared-shell switcher (hanzoai/ui#36;
* this console's switcher was its source), wired to THIS host's contract:
*
* - scope `~/lib/org-scope` (the same orgScope contract, console keys).
* - orgs the lazy, server-paged cross-tenant IAM list
* (`IamAdminApi.organizations` via the gated `/admin/iam` proxy),
* injected ONLY for a super admin; a regular user sees their own
* org synthesized from the scope never fabricated.
* - create the same-origin `/v1/iam/onboard` route (zero-org user
* created + joined as admin; existing user an additional org
* they're scope-switched into).
* - picker the "All organizations" de-scope row (`leaveOrg`).
*
* Selecting re-scopes IN PLACE (`switchOrg` persists + reloads so every module
* refetches under the new `X-Org-Id`); the super-admin masquerade is unchanged.
*/
import { OrgSwitcher as Switcher, type Org, type OrgScope } from '@hanzo/ui/product'
import {
currentOrg,
enterOrg,
hasSelectedOrg,
isScopedAway,
leaveOrg,
setCurrentOrg,
switchOrg,
} from '~/lib/org-scope'
import { ORG_PAGE_SIZE, orgQuery } from '~/lib/org-list'
import { IamAdminApi } from '~/lib/api'
import { v1Url } from '~/lib/api/client'
import { useIsSuperAdmin } from '~/lib/auth/admin'
/** The console's active-org contract, in the hoisted shape. */
const scope: OrgScope = {
currentOrg,
setCurrentOrg,
isScopedAway,
hasSelectedOrg,
enterOrg,
leaveOrg,
switchOrg,
}
/** One page of the cross-tenant org list, mapped to the shared `Org` shape. */
async function orgPage(page: number, query: string): Promise<Org[]> {
const res = await IamAdminApi.organizations(orgQuery(page, query))
return (res.rows ?? []).map((o) => ({ name: o.name, displayName: o.displayName, logo: o.logo }))
}
/** Create an org through the same-origin onboard route → the new org's id. */
async function createOrg(name: string): Promise<string> {
const res = await fetch(v1Url('iam/onboard'), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const data = (await res.json().catch(() => ({}))) as { org?: string; error?: string }
if (!res.ok || !data.org) throw new Error(data.error || `Could not create organization (${res.status}).`)
return data.org
}
export function OrgSwitcher() {
// The admin-gated cross-tenant list fires ONLY for a super admin — a regular
// user (who would 403 it) gets no loader and sees their current org alone.
const isSuperAdmin = useIsSuperAdmin()
return (
<Switcher
scope={scope}
orgs={isSuperAdmin ? orgPage : undefined}
pageSize={ORG_PAGE_SIZE}
create={createOrg}
picker
/>
)
}
+9 -12
View File
@@ -8,15 +8,13 @@ import { useMemo, type ReactNode } from 'react'
import { GuiProvider } from '@hanzo/gui'
import { NextThemeProvider, useRootTheme } from '@hanzogui/next-theme'
import { registerDefaultFields, registerField } from '@hanzo/data'
import { AnalyticsProvider } from '@hanzo/event/react'
import { IamProvider } from '@hanzo/iam/react'
import config from '../../gui.config'
import { SessionProvider } from '~/lib/auth/session'
import { iamConfig } from '~/lib/auth/iam'
import { EntitlementsProvider } from '~/lib/entitlements-context'
import { eventClient } from '~/lib/event'
import { AnalyticsBridge } from './Analytics'
import { AnalyticsBridge, TelemetrySurface } from './Analytics'
import { OrgAccentProvider } from './OrgAccentProvider'
import { RichTextDisplay, RichTextInput } from './fields/RichTextField'
@@ -62,18 +60,17 @@ export function Provider({ children }: { children: ReactNode }) {
<SessionProvider>
<OrgAccentProvider />
{/* Entitlements live inside the session (they read the signed-in account +
active org scope) so the sidebar/launcher/palette gate from ONE fetch. */}
active org scope) so the sidebar/palette gate from ONE fetch. */}
<EntitlementsProvider>
{/* Analytics lives INSIDE the session so `identify` binds the signed-in
actor. The ONE shared `eventClient` (same-origin /v1/event; the tenant is
stamped server-side, so the client never sends an org) is also referenced
by the error boundaries, so every signal rides one stream. The provider
fires the first pageview (autoPageview); `AnalyticsBridge` wires the
per-navigation pageviews + identity. */}
<AnalyticsProvider client={eventClient}>
{/* Telemetry lives INSIDE the session so `identify` binds the signed-in
actor. `TelemetrySurface` is the ONE provider pageviews, errors,
interaction capture, consent and it mounts @hanzo/event internally,
so `useAnalytics()` call sites share its client. `AnalyticsBridge`
adds only identity; the provider owns pageviews. */}
<TelemetrySurface>
<AnalyticsBridge />
{children}
</AnalyticsProvider>
</TelemetrySurface>
</EntitlementsProvider>
</SessionProvider>
</IamProvider>
-186
View File
@@ -1,186 +0,0 @@
'use client'
/**
* PublicLanding what an UNAUTHENTICATED visitor sees at `/` (the marketing face
* of the console). The SAME one `cloud` binary that serves the signed-in console
* serves this to anon one binary, one way, no separate marketing service.
*
* Content is DERIVED from the real product taxonomy (categoriesForBrand +
* CATEGORY_SUMMARY), brand-scoped via getBrand so it is honest (every tile is a
* real category the platform ships) and white-labels for free. The primary CTA is
* the ONE sign-in surface (/signin); "Learn more" goes to the brand's own site.
*/
import { useRouter } from 'next/navigation'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { HanzoHeader, HANZO_PRODUCT_CATEGORIES, findSurfaceByHost, type HanzoSurface } from '@hanzogui/shell'
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { categoriesForBrand, CATEGORY_SUMMARY } from '~/lib/products/brand-scope'
import { landingSurface } from '~/lib/products/landing-surface'
import { ConsoleFooter } from '~/components/ConsoleFooter'
/** The shared header, with its CTAs re-pointed at this landing's own sign-in
* (the canonical surface aims them AT the console a self-link from here). */
const LANDING_SURFACE: HanzoSurface = landingSurface(findSurfaceByHost('cloud.hanzo.ai'))
/**
* Decline the shell's account control. `HanzoHeader` renders its OWN text "Sign in"
* link whenever `account` is nullish (`account ?? <DefaultAccount/>`), so omitting it
* put a second sign-in beside the primary CTA that IS the sign-in the desktop
* logged-out header read `[Get API key] [Sign in] [Sign in]`. `false` is how a caller
* says "no account node": it is not nullish, so the default never renders, and React
* renders nothing for it including in the mobile sheet, which would otherwise draw
* an empty bordered identity row around it. Exactly ONE sign-in affordance.
*/
const NO_ACCOUNT = false
/**
* The house hero buttons the same pill pair every Hanzo landing wears: a white
* primary carrying the weight, and a hairline secondary that is still visibly a
* button. Tamagui's default Button is a grey chip that reads as DISABLED next to
* the white sign-in pill in the header, and `chromeless` has no edge at all.
*/
const CTA_PRIMARY = {
rounded: 999,
bg: '$color12',
color: '$color1',
borderWidth: 0,
hoverStyle: { bg: '$color12', opacity: 0.85 },
pressStyle: { bg: '$color12', opacity: 0.7 },
} as const
const CTA_SECONDARY = {
rounded: 999,
bg: 'transparent',
color: '$color12',
borderWidth: 1,
borderColor: '$color6',
hoverStyle: { bg: '$color3', borderColor: '$color8' },
pressStyle: { bg: '$color4' },
} as const
export function PublicLanding() {
const router = useRouter()
const brand = getBrand()
const categories = categoriesForBrand(brand.id).filter((c) => c !== 'Settings')
const signIn = (
<Button size="$3" onPress={() => router.push('/signin')}>
Sign in
</Button>
)
return (
<YStack minH="100vh" bg="$color1">
{/*
Top bar. On the Hanzo brand this is the UNIFIED @hanzogui/shell HanzoHeader
(Meet Hanzo + the rich ten-category Products mega-menu + brand tokens) the
SAME header every Hanzo surface wears. White-label brands (lux/zoo/) keep the
brand-neutral bar so no Hanzo ecosystem URL leaks onto their console.
*/}
{brand.id === 'hanzo' ? (
// The header's own primary CTA IS the sign-in (see LANDING_SURFACE), so the
// account control is declined explicitly (see NO_ACCOUNT) — one way in, not
// two competing sign-ins.
<HanzoHeader
surface={LANDING_SURFACE}
productsTaxonomy={HANZO_PRODUCT_CATEGORIES}
account={NO_ACCOUNT}
/>
) : (
<XStack
items="center"
justify="space-between"
px="$4"
py="$3"
borderBottomWidth={1}
borderColor="$borderColor"
$md={{ px: '$6' }}
>
<Text fontSize="$6" fontWeight="700" color="$color12">
{config.brandName}
</Text>
{signIn}
</XStack>
)}
{/* Hero */}
<YStack items="center" gap="$4" px="$4" py="$10" $md={{ py: '$12' }}>
<YStack items="center" gap="$3" maxW={760}>
{/* `hz-display` gives the headline a unitless line-height (see globals.css):
the size token's own line-height is tuned for ONE line, so on a phone
where this always wraps the two lines overprint without it. */}
<Text
render="h1"
className="hz-display"
fontSize="$11"
$md={{ fontSize: '$13' }}
fontWeight="800"
color="$color12"
style={{ textAlign: 'center' }}
>
The AI cloud, one platform
</Text>
<Text fontSize="$5" color="$color10" style={{ textAlign: 'center' }}>
Models, compute, training, data, and the tools to ship with usage-based billing and a single API.
</Text>
</YStack>
<XStack gap="$3" mt="$2" flexWrap="wrap" justify="center">
<Button
size="$4"
{...CTA_PRIMARY}
onPress={() => router.push('/signin')}
>
Get started
</Button>
<Button
size="$4"
{...CTA_SECONDARY}
onPress={() => typeof window !== 'undefined' && window.open(brand.websiteUrl, '_blank', 'noopener')}
>
Learn more
</Button>
</XStack>
</YStack>
{/* Real product categories — derived from the live taxonomy */}
<XStack justify="center" px="$4" pb="$10" $md={{ px: '$6' }}>
<YStack width="100%" maxW={1080} gap="$4">
<div
style={{
display: 'grid',
gap: 16,
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
}}
>
{categories.map((c) => (
<YStack
key={c}
gap="$1.5"
p="$4"
rounded="$4"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
>
<Text fontSize="$5" fontWeight="700" color="$color12">
{c}
</Text>
<Text fontSize="$3" color="$color10">
{CATEGORY_SUMMARY[c]}
</Text>
</YStack>
))}
</div>
</YStack>
</XStack>
<XStack justify="center" px="$4" $md={{ px: '$6' }}>
<YStack width="100%" maxW={1080}>
<ConsoleFooter />
</YStack>
</XStack>
</YStack>
)
}
+27 -120
View File
@@ -1,31 +1,35 @@
'use client'
/**
* Scope switcher the project + network pickers that scope every module.
* Scope switcher the NETWORK picker, and only that.
*
* Two chips next to the org switcher: the active PROJECT (or "All projects" for
* org-level scope) and the active NETWORK. The network picker offers the stock
* tiers (Mainnet/Testnet/Devnet the live Hanzo networks), a Local option for a
* self-hosted cloud binary, and any custom networks the user adds (their own
* networkID / EVM chainID / RPC / API). Selecting a network writes through
* `useScope`, which updates the module-level scope the API client reads so it
* re-scopes every module (via `X-Environment`) AND retargets chain/RPC/API at once.
* A "New project" affordance routes to the Projects module; we never fabricate one.
* The network is a global MODE, not a place, so it stays its own control in the
* top-right while org and project condense into the top-left `ContextSwitcher`.
* Its tier dot is a destructive-environment guard (mainnet live green, testnet
* caution amber), which is why it keeps a distinct, always-visible chip rather
* than folding into a menu you have to open to read.
*
* The picker offers the stock tiers (Mainnet/Testnet/Devnet the live Hanzo
* networks), a Local option for a self-hosted cloud binary, and any custom
* networks the user adds (their own networkID / EVM chainID / RPC / API).
* Selecting a network writes through `useScope`, which updates the module-level
* scope the API client reads so it re-scopes every module (via
* `X-Environment`) AND retargets chain/RPC/API at once.
*/
import { useMemo, useState, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { useMemo, useState } from 'react'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { Check, ChevronsUpDown, FolderGit2, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
import { Check, ChevronsUpDown, Layers, Plus, Trash } from '@hanzogui/lucide-icons-2'
import { useScope } from '~/lib/scope-context'
import { STOCK_ENVIRONMENTS } from '~/lib/scope'
import { isStockNetwork, parseCustomNetwork, type Network } from '~/lib/network'
import { FieldText } from '~/components/ui/Field'
import { MenuRow, type DotColor } from '~/components/ui/MenuRow'
import { paper } from '~/components/ui/paper'
import { FieldText } from '@hanzo/ui/product'
/** A small dot keyed to the network tier. Monochrome by default; only the genuine
* states carry a hue mainnet is live (green), testnet is a caution (amber). Every
* other tier is a neutral off the design ladder (Tamagui $colorN). */
type DotColor = '$green10' | '$yellow10' | '$color10' | '$color9' | '$color8'
const NET_DOT: Record<string, DotColor> = {
mainnet: '$green10',
testnet: '$yellow10',
@@ -40,59 +44,6 @@ const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
const idSub = (n: Network): string =>
n.networkID === n.evmChainID ? `Chain ${n.evmChainID}` : `Net ${n.networkID} · Chain ${n.evmChainID}`
function ProjectPicker() {
const router = useRouter()
const { scope, projects, loadingProjects, selectProject } = useScope()
const label = scope.project ? scope.project : 'All projects'
return (
<Popover placement="bottom-end">
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<FolderGit2 size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
{label}
</Button>
</Popover.Trigger>
<Popover.Content bordered elevate p="$2" width={260} bg="$color2" borderColor="$borderColor">
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
Project
</Text>
{/* Org-level scope — no X-Project-Id sent. */}
<Row
label="All projects"
sub="Org-level"
active={!scope.project}
onPress={() => selectProject(undefined)}
/>
{projects.map((p) => (
<Row
key={p.name}
label={p.displayName || p.name}
active={scope.project === p.name}
onPress={() => selectProject(p.name)}
/>
))}
{projects.length === 0 && !loadingProjects ? (
<Text px="$2" py="$1.5" fontSize="$2" color="$color10">
No projects yet.
</Text>
) : null}
<XStack height={1} bg="$borderColor" my="$1" />
<Row
label="New project"
icon={<Plus size={14} />}
onPress={() => router.push('/projects')}
/>
</YStack>
</Popover.Content>
</Popover>
)
}
const EMPTY_FORM = { label: '', networkID: '', evmChainID: '', rpcEndpoint: '', apiEndpoint: '' }
function AddNetworkForm({
@@ -169,16 +120,16 @@ function NetworkPicker() {
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<Layers size={14} />} iconAfter={<ChevronsUpDown size={13} />}>
<XStack items="center" gap="$2">
<YStack width={8} height={8} rounded={999} bg={netDot(activeNetwork)} />
<YStack width={8} height={8} rounded="$10" bg={netDot(activeNetwork)} />
<Text fontSize="$2" color="$color12">
{activeNetwork.label}
</Text>
</XStack>
</Button>
</Popover.Trigger>
<Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
<Popover.Content {...paper} p="$2" width={300}>
<YStack gap="$0.5">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Network
</Text>
@@ -195,11 +146,11 @@ function NetworkPicker() {
{extraEnvs.length ? (
<>
<XStack height={1} bg="$borderColor" my="$1" />
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
<Text px="$2" py="$1" fontSize="$1" color="$color10" fontWeight="500">
Project environments
</Text>
{extraEnvs.map((env) => (
<Row
<MenuRow
key={env}
label={titleCase(env)}
sub="Custom"
@@ -215,7 +166,7 @@ function NetworkPicker() {
{adding ? (
<AddNetworkForm taken={takenCustomIds} onAdd={add} onCancel={() => setAdding(false)} />
) : (
<Row label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
<MenuRow label="Add custom network" icon={<Plus size={14} />} onPress={() => setAdding(true)} />
)}
</YStack>
</Popover.Content>
@@ -238,7 +189,7 @@ function NetworkRow({
return (
<XStack items="center" gap="$1" rounded="$3" hoverStyle={{ bg: '$color4' }}>
<XStack flex={1} onPress={onPress} cursor="pointer" items="center" gap="$2" px="$2" py="$2">
<YStack width={8} height={8} rounded={999} bg={netDot(net)} />
<YStack width={8} height={8} rounded="$10" bg={netDot(net)} />
<YStack flex={1}>
<Text fontSize="$2" color="$color12" numberOfLines={1}>
{net.label}
@@ -262,54 +213,10 @@ function NetworkRow({
)
}
/** One selectable row in a picker popover. */
function Row({
label,
sub,
dot,
icon,
active,
onPress,
}: {
label: string
sub?: string
dot?: DotColor
icon?: ReactNode
active?: boolean
onPress: () => void
}) {
return (
<XStack
onPress={onPress}
cursor="pointer"
items="center"
gap="$2"
px="$2"
py="$2"
rounded="$3"
hoverStyle={{ bg: '$color4' }}
>
{dot ? <YStack width={8} height={8} rounded={999} bg={dot} /> : icon}
<YStack flex={1}>
<Text fontSize="$2" color="$color12" numberOfLines={1}>
{label}
</Text>
{sub ? (
<Text fontSize="$1" color="$color10">
{sub}
</Text>
) : null}
</YStack>
{active ? <Check size={14} /> : null}
</XStack>
)
}
/** Project + network pickers as a unit (topbar). */
/** The network picker (topbar). Org + project live in `ContextSwitcher`, top-left. */
export function ScopeSwitcher() {
return (
<XStack items="center" gap="$1">
<ProjectPicker />
<XStack items="center" gap="$1" data-testid="switcher-network">
<NetworkPicker />
</XStack>
)
+36 -33
View File
@@ -1,21 +1,24 @@
'use client'
/**
* Top-left brand logomark the ONE brand glyph in the console chrome, matching
* the unified Hanzo app-shell (hanzo.app + hanzo.chat): the real geometric mark
* ALONE (no wordmark, no product name, no letter-H text), white-labeled by host.
* Top-left mark the identity of the ORGANIZATION the console is scoped to.
*
* The mark is the host-derived `BrandMark` (Hanzo H / Lux / Zoo / Pars per host,
* `currentColor` so it inherits the calm chrome foreground and adapts to the
* theme) never a hardcoded Hanzo asset. On a lux/zoo/pars host it renders THAT
* brand's mark. Left-click product home (`/`); RIGHT-CLICK a small brand
* context menu (Settings · Brand · Docs · About), the same affordance the shared
* shell's HanzoMark exposes.
* White-label is the point: a customer's console must show the customer's mark,
* never ours. So this renders the ONE shared `OrgMark` (@hanzo/ui) the org's
* own logo when IAM carries one, else the org's MONOGRAM, the same treatment the
* account widget gives a person. It is never the house glyph and never the org
* name set as running text.
*
* The house `BrandMark` still exists, but for the PRODUCT's own moments (the
* assistant trigger, the org picker, onboarding) not for the tenant's slot.
*
* Left-click product home (`/`); RIGHT-CLICK a small brand context menu
* (Settings · Brand · Docs · About), the same affordance the shared shell's
* HanzoMark exposes.
*
* The interactive surface is a plain `<div>` (the console's own escape hatch, as
* used for `<div onScroll>` in OrgSwitcher) so the native `contextmenu` event is
* guaranteed to fire and the mark still inherits the chrome color via
* `currentColor`. The menu reuses the console's one menu surface (a `$color2`
* guaranteed to fire. The menu reuses the console's one menu surface (a `$color2`
* paper sheet with the shared `hz-paper hz-menu-in` styling) no new menu
* system; it is a cursor-anchored overlay (a right-click has no trigger rect to
* anchor a Popover to).
@@ -27,7 +30,9 @@ import { BookOpen, Globe, Info, SlidersHorizontal } from '@hanzogui/lucide-icons
import { config } from '~/config'
import { getBrand } from '~/lib/branding/brands'
import { BrandMark, useOrgLogo } from '~/components/ui/BrandLogo'
import { useOrgIdentity } from '~/components/ui/BrandLogo'
import { Z } from '~/lib/z'
import { OrgMark } from '@hanzo/ui/product'
type MenuItem = {
icon: typeof SlidersHorizontal
@@ -60,7 +65,7 @@ function BrandMenu({ x, y, items, onClose }: { x: number; y: number; items: Menu
const top = typeof window !== 'undefined' ? Math.min(y, window.innerHeight - 200) : y
return (
<div ref={ref} role="menu" style={{ position: 'fixed', left, top, zIndex: 9999 }}>
<div ref={ref} role="menu" style={{ position: 'fixed', left, top, zIndex: Z.dropdown }}>
<YStack
className="hz-paper hz-menu-in"
minW={184}
@@ -99,17 +104,16 @@ function BrandMenu({ x, y, items, onClose }: { x: number; y: number; items: Menu
}
/**
* The top-left brand mark. `collapsed` centers it in the icon rail. Left-click
* The top-left org mark. `collapsed` centers it in the icon rail. Left-click
* home; right-click the brand context menu.
*/
export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
const router = useRouter()
const brand = getBrand()
// White-label: the selected org's OWN logo is the primary chrome identity (the
// tenant's brand, front and center). The host BrandMark is only the fallback when
// the org has set no logo — the `hanzo` org's logo is the Hanzo mark, so it stays
// on-brand. `useOrgLogo` is the ONE cached org-logo source, shared with BrandLogo.
const orgLogo = useOrgLogo()
// The tenant leads the chrome: its own logo when set, else its monogram — never
// the house mark. `useOrgIdentity` is the ONE cached org-identity source.
const org = useOrgIdentity()
const orgLabel = org.displayName || org.name
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
const go = useCallback(
@@ -145,8 +149,8 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
onClick={() => go('/')}
onContextMenu={onContextMenu}
role="link"
aria-label={`${brand.brandName} — home (right-click for brand menu)`}
title={brand.brandName}
aria-label={`${orgLabel} — home (right-click for brand menu)`}
title={orgLabel}
style={{
display: 'flex',
alignItems: 'center',
@@ -157,18 +161,17 @@ export function SidebarBrand({ collapsed, onNavigate }: { collapsed: boolean; on
color: 'var(--color12)',
}}
>
{orgLogo ? (
// The org's own IAM logo, at the brand-mark size — the tenant's brand leads
// the chrome; the Hanzo H is hidden whenever the org has its own.
// eslint-disable-next-line @next/next/no-img-element
<img
src={orgLogo}
alt={brand.brandName}
style={{ height: 24, width: 'auto', maxWidth: 140, objectFit: 'contain', display: 'block' }}
/>
) : (
<BrandMark size={24} />
)}
{/* A logo may be a wordmark, so it is allowed to run wide; the monogram
stays the square tile the account avatar wears.
`data-monogram`: OrgMark is a DISTRIBUTED component that sizes its
monogram glyph proportionally to its tile, so it paints text off the
app type scale by design correct for a mark, wrong to police. The
marker declares that here, at our call site, rather than teaching the
design gate a library's class names. */}
<span data-monogram style={{ display: 'contents' }}>
<OrgMark org={org} size={24} maxW={140} />
</span>
</div>
{menu ? <BrandMenu x={menu.x} y={menu.y} items={items} onClose={() => setMenu(null)} /> : null}
</>
+6 -7
View File
@@ -22,9 +22,8 @@ import { COLOR_SWATCHES } from '~/lib/products/colors'
import { DEFAULT_GROUP, DEFAULT_GROUP_LABEL, type PinGroupView } from '~/lib/products/pins-core'
import { usePins, useProductColors } from '~/lib/products/pins'
import { findEntry } from '~/lib/products/registry'
import { Reorder } from '~/components/ui/Reorder'
import { asColor } from '~/components/ui/color'
import { contrastText } from '~/lib/theme/accent'
import { Reorder, asColor } from '@hanzo/ui/product'
/** A round color swatch button; ringed + checked when selected. */
function SwatchButton({ hex, selected, onPress }: { hex: string; selected: boolean; onPress: () => void }) {
@@ -130,7 +129,7 @@ export function ProductCustomize({ id }: { id: string }) {
{/* Color */}
<YStack gap="$2.5">
<XStack items="center" justify="space-between">
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
<Text fontSize="$2" color="$color11" fontWeight="500">
Icon color
</Text>
<Button size="$1" chromeless onPress={() => resetColor(id)}>
@@ -148,7 +147,7 @@ export function ProductCustomize({ id }: { id: string }) {
{/* Pin */}
<YStack gap="$2.5">
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
<Text fontSize="$2" color="$color11" fontWeight="500">
Pin
</Text>
<Button
@@ -166,7 +165,7 @@ export function ProductCustomize({ id }: { id: string }) {
{/* Group (only meaningful once pinned) */}
{pinned ? (
<YStack gap="$2.5">
<Text fontSize="$2" color="$color11" fontWeight="700" textTransform="uppercase">
<Text fontSize="$2" color="$color11" fontWeight="500">
Group
</Text>
<XStack flexWrap="wrap" gap="$2">
@@ -292,7 +291,7 @@ function ManageGroup({
/>
</XStack>
) : (
<Text flex={1} fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
<Text flex={1} fontSize="$1" color="$color10" fontWeight="500">
{group.label}
</Text>
)}
@@ -357,7 +356,7 @@ export function ManagePins() {
))}
<YStack gap="$2">
<Text fontSize="$1" color="$color10" fontWeight="700" textTransform="uppercase">
<Text fontSize="$1" color="$color10" fontWeight="500">
New group
</Text>
<NewGroup onAdd={addGroup} />
+7 -8
View File
@@ -9,14 +9,17 @@
* invoices the org's own data, scoped to the active org.
* - **Top up** the brand's hosted payment page (`config.payUrl`, e.g. pay.hanzo.ai)
* in a NEW TAB. Display + link only: the wallet never hosts a card form or mints
* credit the hosted page owns payment. **Sign out** beneath.
* credit the hosted page owns payment.
*
* Sign-out is NOT here. It belongs to the ONE account control at the foot of the
* rail (`AccountMenu`); this used to be one of four places that offered it.
*
* The balance comes from the per-tenant `/billing/*` server proxy, scoped to the
* caller's OWN org the exact credit the gateway debits.
*/
import { useRouter } from 'next/navigation'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { ChevronRight, ExternalLink, LogOut, Wallet } from '@hanzogui/lucide-icons-2'
import { ChevronRight, ExternalLink, Wallet } from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
@@ -25,7 +28,7 @@ import { useCloudBalance, spendableCents, balanceSplitLabel } from '~/lib/billin
const fmtUsd = (cents: number): string => `$${(cents / 100).toFixed(2)}`
export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
const { account, signOut } = useSession()
const { account } = useSession()
const router = useRouter()
const owner = account?.owner ?? ''
// ONE shared live balance (same value the Wallet/Cost pages show): refetches on
@@ -48,11 +51,10 @@ export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
}
if (collapsed) {
// Two stacked affordances: wallet/top-up + sign out (the identity is the top avatar).
// One affordance: the wallet. Identity and sign-out are the account control's.
return (
<YStack items="center" gap="$2">
<Button size="$2" chromeless onPress={openTopUp} icon={<Wallet size={18} />} aria-label={`Wallet ${balanceText} — top up`} />
<Button size="$2" chromeless onPress={() => void signOut()} icon={<LogOut size={18} />} aria-label="Sign out" />
</YStack>
)
}
@@ -79,9 +81,6 @@ export function SidebarWallet({ collapsed }: { collapsed: boolean }) {
</YStack>
<Button size="$2" onPress={openTopUp} iconAfter={<ExternalLink size={13} />} aria-label="Top up — opens the payment page in a new tab">Top up</Button>
<Button size="$2" chromeless icon={<LogOut size={15} />} onPress={() => void signOut()} justify="center">
Sign out
</Button>
</YStack>
)
}
+1 -1
View File
@@ -24,9 +24,9 @@ import { useIam } from '@hanzo/iam/react'
import { Text, YStack } from '@hanzo/gui'
import { Loader } from '~/components/ui/Loader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { config } from '~/config'
import { useSession } from '~/lib/auth/session'
import { PrimaryButton } from '@hanzo/ui/product'
export function SignIn() {
const { account, loading } = useSession()
+33 -10
View File
@@ -34,9 +34,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Bot, Plus, Terminal, X } from '@hanzogui/lucide-icons-2'
import { ComboBox, type ComboOption } from '~/components/ui/ComboBox'
import { FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea } from '~/components/ui/Field'
import { SelectMenu, type SelectOption } from '~/components/ui/SelectMenu'
import {
canSubmit,
classifyBuilderError,
@@ -48,6 +45,7 @@ import {
toCreateBody,
} from './logic'
import type { AgentBuilderLoaders, AgentConfig, AgentSpec, BuilderOption, BuilderPrompt, ReasoningEffort } from './types'
import { ComboBox, FieldRow, FieldSelect, FieldSlider, FieldSwitch, FieldText, FieldTextArea, SelectMenu, type ComboOption, type SelectOption } from '@hanzo/ui/product'
/** Async option-list state for the live pickers (model/tool). */
type OptState =
@@ -60,18 +58,32 @@ const CUSTOM = '__custom__'
export function AgentBuilder({
loaders,
initial,
onCreated,
onCancel,
submitLabel = 'Create agent',
}: {
loaders: AgentBuilderLoaders
/** Called after a successful create (the host reloads its list + closes the form). */
onCreated: () => void
/**
* A spec to start from a template's preset, or what a description drafted.
* Read ONCE, at mount: the form is the user's from that point on, so a seed can
* never overwrite something they have already typed. A host that swaps seeds
* (the quickstart, when a different template is picked) remounts with a `key`,
* which states the intent a new starting point instead of hiding it in an
* effect that races the user's keystrokes.
*/
initial?: Partial<AgentSpec>
/**
* Called after a successful create, with the NAME the agent was created under
* (the handle every `/v1/agents/:ref` route is keyed by) so the host can go
* straight to running it rather than looking it back up.
*/
onCreated: (name: string) => void
/** Called when the user cancels (optional — omit for an always-open form). */
onCancel?: () => void
submitLabel?: string
}) {
const [spec, setSpec] = useState<AgentSpec>(emptySpec)
const [spec, setSpec] = useState<AgentSpec>(() => ({ ...emptySpec(), ...initial }))
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [unavailable, setUnavailable] = useState(false)
@@ -167,8 +179,9 @@ export function AgentBuilder({
setError(null)
setUnavailable(false)
try {
await loaders.createAgent(toCreateBody(spec))
onCreated()
const body = toCreateBody(spec)
await loaders.createAgent(body)
onCreated(body.name)
} catch (e) {
const c = classifyBuilderError(e)
if (c.kind === 'unavailable') setUnavailable(true)
@@ -204,7 +217,10 @@ export function AgentBuilder({
loading={models.phase === 'loading'}
error={models.phase === 'error' ? `Model catalog unavailable — type a model id. (${models.message})` : null}
onRetry={loadModels}
placeholder="zen-omni · gpt-4o-mini · claude-sonnet-4-5"
// A placeholder is an example, and an example that does not exist is a lie
// the user only discovers at the agent's first run. These are ids the live
// catalog actually serves; the field itself offers the real list.
placeholder="zen5 · zen5-mini · claude-sonnet-5"
/>
</FieldRow>
@@ -241,7 +257,14 @@ export function AgentBuilder({
loading={tools.phase === 'loading'}
error={tools.phase === 'error' ? `Tool catalog unavailable — type a tool id.` : null}
onRetry={loadTools}
placeholder="add a tool — e.g. web.search, code.exec"
// No invented examples here either: the tool plane is per-org, so nobody
// can name a tool that is certain to exist. The field offers what the org
// has actually activated, and stays typeable for what it has not.
placeholder={
tools.phase === 'ready' && tools.options.length === 0
? 'No tools activated yet — type one to use it anyway'
: 'Search your tools'
}
emptyText="Press Add to include what you typed."
/>
<XStack gap="$2">
+532
View File
@@ -0,0 +1,532 @@
'use client'
/**
* AgentQuickstart the guided way into the ONE builder: describe an agent in your
* own words or start from a template, configure it, run it, and take the call away.
*
* FOUR STEPS, AND EVERY ONE IS A REAL CALL. That is the whole design constraint. A
* ladder of steps is a promise about what happens; a step that only draws a checkmark
* turns the promise into decoration. So:
*
* 1 Describe `POST /v1/chat/completions` drafts a spec from a sentence
* (`draftAgent`), or a template fills the form with a preset
* 2 Configure the SAME `AgentBuilder` every other surface uses, seeded
* 3 Run `POST /v1/agents/:ref/run` executes it and shows the recorded run
* 4 Integrate the request that just worked, as code
*
* Steps 1 and 3 are OPTIONAL by construction: their loaders (`draftAgent`, `runAgent`)
* may be absent, and the step then says exactly what is missing instead of miming it.
* Step 2 is the only one that cannot be skipped, because creating the agent is the
* point and the builder is the one thing that does it.
*
* Host-agnostic like the rest of the module: everything arrives through
* `AgentBuilderLoaders`, so chat, app and bot mount this over the same `/v1/agents`.
*/
import { useMemo, useState } from 'react'
import { Button, Card, Input, ScrollView, Spinner, Text, TextArea, XStack, YStack } from '@hanzo/gui'
import { ArrowRight, Bot, Check, Play, Search, Terminal, X } from '@hanzogui/lucide-icons-2'
import { AgentBuilder } from './AgentBuilder'
import { defaultConfig, emptySpec, proposeName } from './logic'
import { AGENT_TEMPLATES, searchTemplates, specFromTemplate, type AgentTemplate } from './templates'
import type { AgentBuilderLoaders, AgentRunResult, AgentSpec } from './types'
/** The four steps, in order. The id is what the component switches on. */
const STEPS = [
{ id: 'describe', label: 'Describe', endpoint: 'POST /v1/agents' },
{ id: 'configure', label: 'Configure', endpoint: '' },
{ id: 'run', label: 'Run', endpoint: 'POST /v1/agents/:ref/run' },
{ id: 'integrate', label: 'Integrate', endpoint: '' },
] as const
type StepId = (typeof STEPS)[number]['id']
/**
* The step ladder. A step reached earlier is a real link back going back to change
* the prompt is the most common thing a person wants here, and a ladder you cannot
* climb down is a worse version of a heading.
*/
function StepLadder({ current, onGo }: { current: StepId; onGo: (s: StepId) => void }) {
const index = STEPS.findIndex((s) => s.id === current)
return (
<XStack items="center" gap="$2" flexWrap="wrap" role="list" aria-label="Quickstart steps">
{STEPS.map((s, i) => {
const done = i < index
const active = i === index
return (
<XStack key={s.id} items="center" gap="$2" role="listitem">
{i > 0 ? <XStack width={20} height={1} bg="$borderColor" $md={{ width: 32 }} /> : null}
<Button
size="$2"
chromeless
px="$2"
disabled={i > index}
onPress={() => onGo(s.id)}
opacity={i > index ? 0.45 : 1}
aria-current={active ? 'step' : undefined}
aria-label={`Step ${i + 1}: ${s.label}${done ? ' (done)' : ''}`}
>
<XStack items="center" gap="$2">
<XStack
width={20}
height={20}
rounded="$10"
items="center"
justify="center"
bg={done || active ? '$color12' : 'transparent'}
borderWidth={done || active ? 0 : 1}
borderColor="$borderColor"
>
{done ? (
<Check size={12} color="$color1" />
) : (
<Text fontSize="$1" fontWeight="700" color={active ? '$color1' : '$color10'}>
{i + 1}
</Text>
)}
</XStack>
<Text fontSize="$2" fontWeight={active ? '700' : '500'} color={active ? '$color12' : '$color10'}>
{s.label}
</Text>
{active && s.endpoint ? (
<Text fontSize="$1" color="$color9" fontFamily="$mono" display="none" $md={{ display: 'flex' }}>
{s.endpoint}
</Text>
) : null}
</XStack>
</Button>
</XStack>
)
})}
</XStack>
)
}
/** One template card in the gallery. The whole card is the control. */
function TemplateCard({ template, onPick }: { template: AgentTemplate; onPick: () => void }) {
return (
<YStack
onPress={onPick}
cursor="pointer"
role="button"
tabIndex={0}
focusable
onKeyDown={(e: { key?: string; preventDefault?: () => void }) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault?.()
onPick()
}
}}
gap="$1.5"
p="$3"
rounded="$4"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
hoverStyle={{ bg: '$color3', borderColor: '$color8' }}
aria-label={`Start from ${template.title}`}
>
<Text fontSize="$3" fontWeight="700" color="$color12">
{template.title}
</Text>
<Text fontSize="$2" color="$color11">
{template.summary}
</Text>
</YStack>
)
}
/** A short, quiet note — used wherever a step has to say what is missing. */
function Note({ children }: { children: React.ReactNode }) {
return (
<Text fontSize="$2" color="$color10">
{children}
</Text>
)
}
export function AgentQuickstart({
loaders,
onFinished,
apiBase = 'https://api.hanzo.ai',
}: {
loaders: AgentBuilderLoaders
/** Called when the user leaves the quickstart with an agent created (host reloads). */
onFinished?: (name: string) => void
/** The API origin the integrate snippet should show. */
apiBase?: string
}) {
const [step, setStep] = useState<StepId>('describe')
const [seed, setSeed] = useState<Partial<AgentSpec>>({})
// Bumped whenever a NEW starting point is chosen, so the builder remounts on it
// rather than an effect racing whatever the user has already typed.
const [seedKey, setSeedKey] = useState(0)
const [created, setCreated] = useState<string | null>(null)
// ── Step 1: describe ──────────────────────────────────────────────────────
const [description, setDescription] = useState('')
const [drafting, setDrafting] = useState(false)
const [draftError, setDraftError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const templates = useMemo(() => searchTemplates(query), [query])
const start = (next: Partial<AgentSpec>) => {
setSeed(next)
setSeedKey((k) => k + 1)
setStep('configure')
}
const pickTemplate = (t: AgentTemplate) => start(specFromTemplate(t, emptySpec(), defaultConfig()))
const describe = async () => {
const text = description.trim()
if (!text || drafting) return
// Whatever happens next, the user's own words are already worth something: they
// are the description, and they propose the handle. A draft only ever ADDS to
// this, so a failed or absent draft still lands them in a part-filled form.
const fallback: Partial<AgentSpec> = { description: text, name: proposeName(text) }
if (!loaders.draftAgent) {
start(fallback)
return
}
setDrafting(true)
setDraftError(null)
try {
const drafted = await loaders.draftAgent(text)
start({ ...fallback, ...drafted })
} catch (e) {
// Say why, and still go — being stranded on a spinner is worse than writing
// the prompt yourself.
setDraftError(e instanceof Error ? e.message : 'Could not draft this one — write the prompt yourself.')
start(fallback)
} finally {
setDrafting(false)
}
}
// ── Step 3: run ───────────────────────────────────────────────────────────
const [input, setInput] = useState('')
const [running, setRunning] = useState(false)
const [run, setRun] = useState<AgentRunResult | null>(null)
const [runError, setRunError] = useState<string | null>(null)
const doRun = async () => {
const text = input.trim()
if (!text || !created || !loaders.runAgent || running) return
setRunning(true)
setRunError(null)
setRun(null)
try {
setRun(await loaders.runAgent(created, text))
} catch (e) {
// A failed run answers 502 with the RUN as its body, so this message is the
// run's own reason — not a generic transport failure.
setRunError(e instanceof Error ? e.message : 'The run did not complete.')
} finally {
setRunning(false)
}
}
const snippet = useMemo(
() =>
[
`curl ${apiBase}/v1/agents/${created ?? 'your-agent'}/run \\`,
` -H "Authorization: Bearer $HANZO_API_KEY" \\`,
` -H "Content-Type: application/json" \\`,
` -d '{"input":"${(input.trim() || 'your message here').replace(/'/g, "'\\''").replace(/"/g, '\\"')}"}'`,
].join('\n'),
[apiBase, created, input],
)
return (
<YStack gap="$4">
<StepLadder current={step} onGo={setStep} />
{/* ── 1 · Describe ─────────────────────────────────────────────────── */}
{step === 'describe' ? (
<XStack gap="$4" items="flex-start" flexWrap="wrap">
<YStack flex={2} minW={320} gap="$3" py="$6">
<YStack gap="$2" items="center" py="$4">
<Text fontSize="$8" fontWeight="800" color="$color12" style={{ textAlign: 'center' }}>
What do you want to build?
</Text>
<Text fontSize="$3" color="$color11" style={{ textAlign: 'center' }}>
Describe your agent, or start from a template.
</Text>
</YStack>
<YStack
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
rounded="$7"
px="$3"
py="$2.5"
gap="$2"
data-field-box
>
<XStack gap="$2" items="flex-end">
<TextArea
flex={1}
value={description}
onChangeText={setDescription}
placeholder="Describe your agent…"
numberOfLines={3}
disabled={drafting}
borderWidth={0}
bg="transparent"
px="$1"
py="$1"
aria-label="Describe your agent"
// Enter sends, Shift+Enter is a newline, and a key mid-IME-composition
// is never a send — an open candidate window must not submit the turn.
onKeyDown={(e) => {
const ev = e as unknown as {
key?: string
shiftKey?: boolean
preventDefault?: () => void
nativeEvent?: { isComposing?: boolean }
}
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
ev.preventDefault?.()
void describe()
}
}}
/>
<Button
size="$2"
circular
theme="light"
disabled={!description.trim() || drafting}
onPress={() => void describe()}
icon={drafting ? undefined : <ArrowRight size={16} />}
aria-label="Draft this agent"
>
{drafting ? <Spinner size="small" /> : undefined}
</Button>
</XStack>
</YStack>
{!loaders.draftAgent ? (
<Note>
Drafting isnt connected here, so your words become the agents description and handle and you
write the prompt in the next step.
</Note>
) : null}
{draftError ? (
<Text fontSize="$2" color="$red10">
{draftError}
</Text>
) : null}
</YStack>
{/* Templates a real gallery, searchable, each card a preset the builder
can already express. */}
<YStack flex={1} minW={280} gap="$2.5" p="$3" rounded="$5" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$4" fontWeight="700" color="$color12">
Browse templates
</Text>
<XStack
items="center"
gap="$2"
px="$2.5"
height={34}
rounded="$3"
borderWidth={1}
borderColor="$borderColor"
bg="$color2"
data-field-box
>
<Search size={14} opacity={0.6} />
<Input
flex={1}
unstyled
value={query}
onChangeText={setQuery}
placeholder="Search templates"
fontSize="$3"
color="$color12"
autoCapitalize="none"
autoCorrect={false}
aria-label="Search templates"
/>
{query ? (
<Button size="$1" chromeless icon={<X size={13} />} onPress={() => setQuery('')} aria-label="Clear search" />
) : null}
</XStack>
<ScrollView maxH={520}>
<YStack gap="$2">
{templates.map((t) => (
<TemplateCard key={t.id} template={t} onPick={() => pickTemplate(t)} />
))}
{templates.length === 0 ? (
<Note>No template matches {query.trim()}. Describe it instead that always works.</Note>
) : null}
</YStack>
</ScrollView>
</YStack>
</XStack>
) : null}
{/* ── 2 · Configure ────────────────────────────────────────────────── */}
{step === 'configure' ? (
<YStack gap="$3" maxW={720}>
<AgentBuilder
key={seedKey}
loaders={loaders}
initial={seed}
onCancel={() => setStep('describe')}
onCreated={(name) => {
setCreated(name)
setStep('run')
onFinished?.(name)
}}
/>
</YStack>
) : null}
{/* ── 3 · Run ──────────────────────────────────────────────────────── */}
{step === 'run' && created ? (
<YStack gap="$3" maxW={720}>
<XStack items="center" gap="$2">
<Bot size={16} />
<Text fontSize="$5" fontWeight="800" color="$color12">
{created}
</Text>
<Text fontSize="$2" color="$color10">
is live
</Text>
</XStack>
<Text fontSize="$2" color="$color11">
Send it something. This runs the agent for real and bills the run to your organization.
</Text>
{loaders.runAgent ? (
<>
<YStack
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
rounded="$5"
px="$3"
py="$2.5"
data-field-box
>
<XStack gap="$2" items="flex-end">
<TextArea
flex={1}
value={input}
onChangeText={setInput}
placeholder="Your message to the agent…"
numberOfLines={3}
disabled={running}
borderWidth={0}
bg="transparent"
px="$1"
py="$1"
aria-label="Message to the agent"
onKeyDown={(e) => {
const ev = e as unknown as {
key?: string
shiftKey?: boolean
preventDefault?: () => void
nativeEvent?: { isComposing?: boolean }
}
if (ev.key === 'Enter' && !ev.shiftKey && !ev.nativeEvent?.isComposing) {
ev.preventDefault?.()
void doRun()
}
}}
/>
<Button
size="$2"
theme="light"
disabled={!input.trim() || running}
onPress={() => void doRun()}
icon={running ? undefined : <Play size={15} />}
>
{running ? <Spinner size="small" /> : 'Run'}
</Button>
</XStack>
</YStack>
{runError ? (
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$3" fontWeight="700" color="$red10">
The run failed
</Text>
<Text fontSize="$2" color="$color11">
{runError}
</Text>
</Card>
) : null}
{run ? (
<Card gap="$2" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$2" flexWrap="wrap">
<Text fontSize="$2" fontWeight="700" color={run.status === 'ok' ? '$green10' : '$red10'}>
{run.status === 'ok' ? 'ok' : run.status || 'error'}
</Text>
{run.model ? (
<Text fontSize="$1" color="$color10">
{run.model}
</Text>
) : null}
{run.durationMs != null ? (
<Text fontSize="$1" color="$color10">
{run.durationMs} ms
</Text>
) : null}
</XStack>
<Text fontSize="$3" color="$color12">
{run.output || run.error || 'The run recorded no output.'}
</Text>
</Card>
) : null}
</>
) : (
<Card gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$2">
<Terminal size={14} />
<Text fontSize="$3" fontWeight="700" color="$color12">
Running from here isnt connected on this deployment
</Text>
</XStack>
<Text fontSize="$2" color="$color11">
The agent exists and `POST /v1/agents/{created}/run` is its endpoint the next step shows the
call.
</Text>
</Card>
)}
<XStack gap="$2">
<Button flex={1} theme="light" iconAfter={<ArrowRight size={15} />} onPress={() => setStep('integrate')}>
Integrate
</Button>
</XStack>
</YStack>
) : null}
{/* ── 4 · Integrate ────────────────────────────────────────────────── */}
{step === 'integrate' && created ? (
<YStack gap="$3" maxW={720}>
<Text fontSize="$5" fontWeight="800" color="$color12">
Call it from your code
</Text>
<Text fontSize="$2" color="$color11">
The same request the Run step just made. Mint a key under API keys and set it as `HANZO_API_KEY`.
</Text>
<YStack p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<Text fontSize="$2" fontFamily="$mono" color="$color12" style={{ whiteSpace: 'pre-wrap' }}>
{snippet}
</Text>
</YStack>
<Note>
It answers with the recorded run its id, status, model, output and duration. A model failure comes
back as a run with `status: "error"` and the reason, never as silence.
</Note>
</YStack>
) : null}
</YStack>
)
}
+8
View File
@@ -9,10 +9,12 @@
* lifts cleanly into a published `@hanzo/agent-builder` package.
*/
export { AgentBuilder } from './AgentBuilder'
export { AgentQuickstart } from './Quickstart'
export type {
AgentSpec,
AgentConfig,
AgentCreateBody,
AgentRunResult,
ReasoningEffort,
AgentBuilderLoaders,
BuilderOption,
@@ -34,4 +36,10 @@ export {
promptBodyFromRow,
promptOptions,
classifyBuilderError,
draftInstruction,
parseDraft,
proposeName,
toHandle,
} from './logic'
export { AGENT_TEMPLATES, matchTemplate, searchTemplates, templateById, specFromTemplate } from './templates'
export type { AgentTemplate } from './templates'
+82 -5
View File
@@ -14,6 +14,9 @@ import {
promptBodyFromRow,
promptOptions,
classifyBuilderError,
proposeName,
toHandle,
parseDraft,
} from './logic'
import type { AgentConfig, AgentSpec, BuilderOption, BuilderPrompt } from './types'
@@ -32,13 +35,21 @@ describe('defaultModel', () => {
expect(defaultModel([])).toBe('')
})
it('prefers the exact zen-omni default when present', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen-omni'), opt('claude')])).toBe('zen-omni')
it('prefers the exact zen5 default when present', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen5'), opt('claude')])).toBe('zen5')
})
it('falls back to the first Zen-family model (prefix or provider hint)', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen-coder')])).toBe('zen-coder')
expect(defaultModel([opt('gpt-4o'), opt('some-model', 'Zen')])).toBe('some-model')
it('falls back to another Zen TEXT model', () => {
expect(defaultModel([opt('gpt-4o'), opt('zen5-coder')])).toBe('zen5-coder')
})
// The defect this rule exists to prevent: a catalog arrives sorted, so a loose
// `^zen[-.]` test selected `zen-embedding` — an embeddings SKU that cannot hold a
// conversation — as the default model for every new agent.
it('never defaults to a modality SKU over a text model', () => {
const live = [opt('zen-embedding'), opt('zen-image'), opt('zen-rerank'), opt('zen-vl'), opt('zen5'), opt('zen5-mini')]
expect(defaultModel(live)).toBe('zen5')
expect(defaultModel(live.filter((o) => o.value !== 'zen5'))).toBe('zen5-mini')
})
it('falls back to the first catalog id when no Zen model exists', () => {
@@ -189,3 +200,69 @@ describe('classifyBuilderError', () => {
expect(classifyBuilderError('boom')).toEqual({ kind: 'error', message: 'Could not create the agent.' })
})
})
describe('proposeName', () => {
it('makes a handle out of the words a person actually typed', () => {
expect(proposeName('An agent that triages support tickets')).toBe('triages-support-tickets')
})
it('drops noise words and punctuation', () => {
expect(proposeName('The agent for our billing!! questions')).toBe('billing-questions')
})
it('is empty when there is nothing usable', () => {
expect(proposeName(' ')).toBe('')
expect(proposeName('a the it')).toBe('')
})
it('caps the length so the handle stays a handle', () => {
expect(proposeName('extraordinarily verbose descriptive nomenclature').length).toBeLessThanOrEqual(32)
})
})
describe('toHandle', () => {
it('reshapes without re-wording — a handle survives intact', () => {
expect(toHandle('support-agent')).toBe('support-agent')
expect(toHandle('Support Triage Bot!')).toBe('support-triage-bot')
})
it('collapses runs and trims the edges', () => {
expect(toHandle(' --a // b-- ')).toBe('a-b')
})
it('caps the length and never ends on a hyphen', () => {
const h = toHandle('extraordinarily verbose descriptive nomenclature here')
expect(h.length).toBeLessThanOrEqual(32)
expect(h.endsWith('-')).toBe(false)
})
})
describe('parseDraft', () => {
it('reads the three fields it asked for', () => {
const d = parseDraft('{"name":"support-triage","description":"Triages tickets.","systemPrompt":"You triage."}')
expect(d).toEqual({ name: 'support-triage', description: 'Triages tickets.', systemPrompt: 'You triage.' })
})
// The two things models actually do to JSON.
it('survives a code fence and surrounding prose', () => {
const answer = 'Sure! Here you go:\n```json\n{"name":"helper","systemPrompt":"You help."}\n```\nHope that works.'
expect(parseDraft(answer)).toEqual({ name: 'helper', systemPrompt: 'You help.' })
})
it('normalizes a handle the backend would refuse', () => {
expect(parseDraft('{"name":"Support Triage Bot!"}')?.name).toBe('support-triage-bot')
})
it('accepts the snake_case and bare spellings of the prompt', () => {
expect(parseDraft('{"system_prompt":"You help."}')?.systemPrompt).toBe('You help.')
expect(parseDraft('{"prompt":"You help."}')?.systemPrompt).toBe('You help.')
})
// A creative answer may only ever produce LESS than asked, never a field the
// builder cannot express.
it('drops every key it does not recognize', () => {
const d = parseDraft('{"name":"a-b","model":"gpt-9","tools":["rm -rf"],"webhook":"http://evil"}')
expect(d).toEqual({ name: 'a-b' })
})
it('is null when there is no object, or only empty fields', () => {
expect(parseDraft('I could not do that.')).toBeNull()
expect(parseDraft('{ not json }')).toBeNull()
expect(parseDraft('{"name":" ","description":""}')).toBeNull()
})
})
+121 -9
View File
@@ -23,23 +23,30 @@ export function defaultConfig(): AgentConfig {
return { temperature: 0.7, topP: 1, topK: 0, stream: true, thinking: false, useTools: true, webSearch: false }
}
/** The default Zen model to preselect when the catalog offers one. */
const ZEN_DEFAULT = 'zen-omni'
/** The Zen text model to preselect when the catalog offers it. */
const ZEN_DEFAULT = 'zen5'
/**
* Pick a sensible default model from a live catalog: the Zen default if present,
* else the first Zen (`hanzo`-owned / `zen-` prefixed) model, else the first
* catalog id, else '' (nothing to default to the field stays empty/typeable).
* PURE. Never invents an id only returns one the catalog actually lists.
* else another model from the Zen TEXT family, else the first catalog id, else ''
* (nothing to default to the field stays empty/typeable). PURE. Never invents an
* id only returns one the catalog actually lists.
*
* The text-family test is `zen5…`, and that specificity is load-bearing. Zen's naming
* splits cleanly: `zen5`, `zen5-mini`, `zen5-flash`, `zen5-coder`, `zen5-pro` are the
* text models, while `zen-<noun>` names a MODALITY zen-embedding, zen-image,
* zen-video, zen-rerank, zen-voice, zen-vl. A looser `^zen[-.]` test matched both, and
* since a catalog arrives sorted it selected `zen-embedding`: every agent created
* without touching the model field was pointed at an embeddings SKU that cannot hold a
* conversation. (It went unnoticed because the exact-match arm named `zen-omni`, which
* the live catalog does not carry, so the fallback was always the arm that ran.)
*/
export function defaultModel(options: BuilderOption[]): string {
if (options.length === 0) return ''
const exact = options.find((o) => o.value === ZEN_DEFAULT)
if (exact) return exact.value
const zen = options.find(
(o) => /^zen[-.]/i.test(o.value) || (o.hint ?? '').toLowerCase().includes('zen'),
)
return (zen ?? options[0]).value
const zenText = options.find((o) => /^zen\d/i.test(o.value))
return (zenText ?? options[0]).value
}
/** True iff the spec can be submitted (a non-empty trimmed name is the only requirement). */
@@ -150,6 +157,111 @@ export function promptOptions(prompts: BuilderPrompt[]): BuilderOption[] {
return prompts.map((p) => ({ value: p.name, label: p.label ?? p.name, hint: p.hint }))
}
// ── Drafting an agent from a sentence ───────────────────────────────────────
//
// The quickstart lets someone describe an agent in their own words. That is a
// model call, so the EFFECT is an injected loader (`draftAgent`) like every other;
// what lives here is the pure half — the instruction we send, and the parse of what
// comes back. Both are pure so the fragile part (reading a model's JSON) is tested
// against real malformed answers rather than trusted.
/**
* The instruction that turns a description into a spec. It asks for the three
* fields a person would otherwise type and NOTHING else deliberately not `model`
* or `tools`: a model id must exist in the org's live catalog and a tool must exist
* in its tool plane, and a model asked to name one will happily invent it. Those two
* fields stay with the pickers that know the real answers. PURE.
*/
export function draftInstruction(): string {
return [
'You turn a description of an agent into its definition.',
'',
'Reply with ONE JSON object and nothing else — no prose, no code fence. Keys:',
' "name" a short lowercase handle, words joined by hyphens (e.g. support-triage)',
' "description" one sentence on what the agent does',
' "systemPrompt" the agent\'s own instructions, written in the second person',
'',
'The system prompt is the real work: state what the agent does, what it must not do,',
'and how it should behave when it is unsure. Write it as instructions to the agent,',
'not as a description of it.',
].join('\n')
}
/** Stop-words that carry no meaning in a handle. */
const NOISE = new Set(['a', 'an', 'the', 'that', 'this', 'my', 'our', 'for', 'to', 'of', 'and', 'is', 'it', 'agent'])
/**
* Put any string into handle FORM: lowercase, letters and digits kept, everything
* else a hyphen, no repeated or trailing hyphens, capped. It reshapes and never
* re-words `support-agent` stays `support-agent`. PURE.
*/
export function toHandle(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32)
.replace(/-+$/, '')
}
/**
* A handle proposed from PROSE the user's own sentence so the form is never left
* with an empty required field even when the draft call fails. Drops the words that
* carry no meaning in a handle, keeps the first three that do, and puts the result in
* handle form. Returns '' when the text carries nothing usable.
*
* Distinct from `toHandle` on purpose, and the two must not be confused: this one
* REWORDS, which is right for a sentence and wrong for a handle. Running it over an
* already-formed handle silently renames it `support-agent` would come back as
* `support`, because "agent" is noise in a sentence and load-bearing in a name. PURE.
*/
export function proposeName(description: string): string {
const words = description
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, ' ')
.split(/[\s-]+/)
.filter((w) => w.length > 1 && !NOISE.has(w))
return toHandle(words.slice(0, 3).join('-'))
}
/**
* Read a drafted spec out of a model's answer. Tolerant of the two things models
* actually do wrapping the object in a ```json fence, and adding a sentence before
* or after it by taking the outermost braces. Every field is validated and
* anything unrecognized is DROPPED, so a creative answer can only ever produce less
* than asked, never a field the builder does not understand. Returns null when there
* is no object at all. PURE.
*/
export function parseDraft(answer: string): Partial<AgentSpec> | null {
const start = answer.indexOf('{')
const end = answer.lastIndexOf('}')
if (start < 0 || end <= start) return null
let raw: unknown
try {
raw = JSON.parse(answer.slice(start, end + 1))
} catch {
return null
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
const r = raw as Record<string, unknown>
const text = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
const out: Partial<AgentSpec> = {}
const name = text(r.name)
// A handle the backend would refuse is worse than none, so reshape it — but with
// `toHandle`, which only changes the FORM. `proposeName` would also re-word it, and
// the model was asked for a handle, not a sentence.
if (name) {
const handle = toHandle(name)
if (handle) out.name = handle
}
const description = text(r.description)
if (description) out.description = description
const systemPrompt = text(r.systemPrompt) ?? text(r.system_prompt) ?? text(r.prompt)
if (systemPrompt) out.systemPrompt = systemPrompt
return Object.keys(out).length ? out : null
}
/**
* Classify a create failure. A 404 (or an explicit "unavailable" BackendState kind)
* means the `/v1/agents` route isn't bound on this deployment an honest "not
@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest'
import { AGENT_TEMPLATES, matchTemplate, searchTemplates, specFromTemplate, templateById } from './templates'
import { defaultConfig, emptySpec, toCreateBody } from './logic'
describe('AGENT_TEMPLATES', () => {
it('has unique ids and a handle for every entry', () => {
const ids = AGENT_TEMPLATES.map((t) => t.id)
expect(new Set(ids).size).toBe(ids.length)
for (const t of AGENT_TEMPLATES) {
expect(t.name.trim()).not.toBe('')
expect(t.title.trim()).not.toBe('')
expect(t.summary.trim()).not.toBe('')
}
})
it('leads with the blank one — starting from nothing is the honest default', () => {
expect(AGENT_TEMPLATES[0].id).toBe('blank')
expect(AGENT_TEMPLATES[0].systemPrompt).toBe('')
})
// The whole point of the module doc: a template is a preset, never a promise. It
// may only carry fields the create body can already express, so picking one can
// never produce an agent the builder itself could not.
it('carries nothing the create body cannot express', () => {
const allowed = new Set(['id', 'title', 'summary', 'name', 'systemPrompt', 'config'])
for (const t of AGENT_TEMPLATES) {
for (const key of Object.keys(t)) expect(allowed.has(key)).toBe(true)
}
})
// A hardcoded tool id would name something the org may not have activated, and it
// would fail at the agent's FIRST invocation rather than here. Tools come from the
// live tool plane or not at all.
it('names no tools — those come from the live tool plane', () => {
for (const t of AGENT_TEMPLATES) expect(t).not.toHaveProperty('tools')
})
it('every template produces a submittable body', () => {
for (const t of AGENT_TEMPLATES) {
const body = toCreateBody(specFromTemplate(t, emptySpec(), defaultConfig()))
expect(body.name).toBe(t.name)
expect(body.description).toBe(t.summary)
}
})
})
describe('matchTemplate / searchTemplates', () => {
const t = AGENT_TEMPLATES.find((x) => x.id === 'researcher')!
it('an empty query matches everything', () => {
expect(matchTemplate(t, '')).toBe(true)
expect(matchTemplate(t, ' ')).toBe(true)
expect(searchTemplates('')).toHaveLength(AGENT_TEMPLATES.length)
})
it('matches title, summary and id, case-insensitively', () => {
expect(matchTemplate(t, 'DEEP')).toBe(true)
expect(matchTemplate(t, 'sources')).toBe(true)
expect(matchTemplate(t, 'researcher')).toBe(true)
})
it('returns nothing for a query nothing carries', () => {
expect(searchTemplates('quantum tuba')).toEqual([])
})
it('keeps gallery order', () => {
const found = searchTemplates('a').map((x) => x.id)
expect(found).toEqual(AGENT_TEMPLATES.filter((x) => matchTemplate(x, 'a')).map((x) => x.id))
})
})
describe('templateById', () => {
it('finds one, and is null for an unknown id', () => {
expect(templateById('blank')?.title).toBe('Blank agent')
expect(templateById('nope')).toBeNull()
})
})
describe('specFromTemplate', () => {
const t = AGENT_TEMPLATES.find((x) => x.id === 'extractor')!
it('fills name, description and prompt from the template', () => {
const s = specFromTemplate(t, emptySpec(), defaultConfig())
expect(s.name).toBe(t.name)
expect(s.description).toBe(t.summary)
expect(s.systemPrompt).toBe(t.systemPrompt)
})
// The template owns the agent's character; the MODEL is the org's own decision and
// its tool list is the org's too, so neither is overwritten by picking one.
it('keeps a model and tools the user already chose', () => {
const current = { ...emptySpec(), model: 'zen5-pro', tools: ['already.picked'] }
const s = specFromTemplate(t, current, defaultConfig())
expect(s.model).toBe('zen5-pro')
expect(s.tools).toEqual(['already.picked'])
})
it('merges the template config over the defaults, leaving the rest alone', () => {
const s = specFromTemplate(t, emptySpec(), defaultConfig())
expect(s.config?.temperature).toBe(0)
expect(s.config?.stream).toBe(defaultConfig().stream)
})
it('posts no config for a template that needs none', () => {
const blank = templateById('blank')!
expect(specFromTemplate(blank, emptySpec(), defaultConfig()).config).toBeUndefined()
expect(toCreateBody(specFromTemplate(blank, emptySpec(), defaultConfig()))).not.toHaveProperty('config')
})
})
+175
View File
@@ -0,0 +1,175 @@
/**
* Agent templates starting points for the ONE builder, shared by every surface.
*
* A template is a PRESET, never a promise: every field it carries maps to something
* `POST /v1/agents` already accepts (`name`, `description`, `systemPrompt`, and the
* `config` knobs in `AgentConfig`). Picking one fills the builder and nothing else
* happens the user still sees, edits and submits the same form, so a template can
* never create an agent the builder itself could not.
*
* Deliberately NO tool ids. Tools come from the live tool plane (`GET /v1/tools`),
* which knows what an org has actually activated; a hardcoded `web.search` here would
* name something that may not exist and would fail on the agent's first invocation.
* What a template CAN say about tools is the truth: `useTools` and `webSearch` are
* real switches in the agent contract, so a template that needs them turns them on
* and the builder's live tool picker fills in the specifics.
*
* Pure data + pure helpers no React, no I/O so this lifts into
* `@hanzo/agent-builder` with the rest of the module.
*/
import type { AgentConfig, AgentSpec } from './types'
/** A named starting point: what it is, and the spec it fills the builder with. */
export type AgentTemplate = {
/** Stable id — the URL/search key. */
id: string
/** What it is called in the gallery. */
title: string
/** One line on what the agent does. Shown on the card and searched. */
summary: string
/** The seed handle; the user renames freely before submitting. */
name: string
/** The system prompt this template starts from ('' for the blank one). */
systemPrompt: string
/** Only the knobs this template genuinely needs; the rest stay at their defaults. */
config?: Partial<AgentConfig>
}
/**
* The gallery, in display order. `blank` leads because starting from nothing is the
* honest default everything after it is a real, specific job.
*/
export const AGENT_TEMPLATES: readonly AgentTemplate[] = [
{
id: 'blank',
title: 'Blank agent',
summary: 'A starting point with nothing assumed — name it, pick a model, write the prompt.',
name: 'my-agent',
systemPrompt: '',
},
{
id: 'researcher',
title: 'Deep researcher',
summary: 'Researches a question across the web and answers with the sources it used.',
name: 'researcher',
systemPrompt:
'You research questions and report what you found.\n\n' +
'Work in steps: decide what you need to know, search for it, read the results, and only then answer. ' +
'Prefer primary sources over summaries of them.\n\n' +
'Every claim that came from a source carries that source. When sources disagree, say so and give both. ' +
'When you could not find something, say that plainly instead of filling the gap — an honest gap is more ' +
'useful than a confident guess.',
config: { webSearch: true, thinking: true, reasoningEffort: 'high' },
},
{
id: 'extractor',
title: 'Structured extractor',
summary: 'Reads unstructured text and returns one typed JSON object, or says which fields were absent.',
name: 'extractor',
systemPrompt:
'You turn unstructured text into one JSON object matching the schema the caller gives you.\n\n' +
'Return the object and nothing else — no prose, no code fence, no explanation.\n\n' +
'Copy values from the text; never infer one that is not there. A field the text does not support is null, ' +
'and a guessed value is a defect. If the schema is ambiguous about a field, choose the reading that the ' +
'text supports literally.',
config: { temperature: 0, topP: 1 },
},
{
id: 'support',
title: 'Support answerer',
summary: 'Answers product questions from your own material, and escalates the ones it cannot.',
name: 'support',
systemPrompt:
'You answer product questions for customers, using the material available to you.\n\n' +
'Answer from that material only. When it does not cover the question, say so and hand off rather than ' +
'improvising — a wrong answer costs more than a slow one.\n\n' +
'Lead with the answer, then the steps. Keep it short enough to act on. Never promise a behaviour, a date ' +
'or a refund you cannot point to in the material.',
config: { useTools: true, temperature: 0.3 },
},
{
id: 'reviewer',
title: 'Code reviewer',
summary: 'Reads a diff and reports what will actually break, most severe first.',
name: 'reviewer',
systemPrompt:
'You review code changes.\n\n' +
'Report only defects you can name concretely: the input or state that triggers them, and the wrong output ' +
'or crash that results. Correctness and security first, then clarity. Rank by severity.\n\n' +
'Style preferences are not findings. Neither is a concern you cannot demonstrate — if you are unsure a ' +
'thing is real, say you are unsure rather than listing it as a defect. Finding nothing is a valid review.',
config: { thinking: true, reasoningEffort: 'high', temperature: 0.2 },
},
{
id: 'analyst',
title: 'Data analyst',
summary: 'Explains a dataset — what is in it, what stands out, and what to check next.',
name: 'analyst',
systemPrompt:
'You explain datasets to people who have to make a decision from them.\n\n' +
'Start with the shape: how many rows, which columns, what period, and what is missing. Then the two or ' +
'three things that genuinely stand out. Then what you would check next and why.\n\n' +
'Every number you state comes from the data. Distinguish what the data shows from what you suspect, and ' +
'name the limits — a sample too small to conclude from is the finding, not an obstacle to one.',
config: { useTools: true, temperature: 0.2 },
},
{
id: 'summarizer',
title: 'Meeting summarizer',
summary: 'Turns a transcript into decisions, owners and the questions still open.',
name: 'summarizer',
systemPrompt:
'You turn meeting transcripts into something the people who missed it can act on.\n\n' +
'Three sections: decisions made, actions with their owner, and questions left open. Nothing else.\n\n' +
'Only record a decision that was actually reached — a topic discussed without resolution belongs under ' +
'open questions. Attribute an action to a person only when the transcript names them; otherwise leave the ' +
'owner unassigned and say so.',
config: { temperature: 0.2 },
},
{
id: 'triage',
title: 'Incident triager',
summary: 'Classifies an incoming report by severity and area, and drafts the first reply.',
name: 'triage',
systemPrompt:
'You triage incoming incident reports.\n\n' +
'For each one give: severity, the area it belongs to, what is affected, and a first reply to the reporter.\n\n' +
'Severity follows blast radius, not tone — a calm report of data loss outranks an urgent one about a ' +
'typo. When the report lacks what you need to classify it, the first reply asks for exactly that and the ' +
'severity stays provisional. Never guess an area to avoid leaving one blank.',
config: { temperature: 0.2, reasoningEffort: 'medium' },
},
]
/** Case-insensitive, whitespace-tolerant match over the fields a person would type. */
export function matchTemplate(t: AgentTemplate, query: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
return `${t.title} ${t.summary} ${t.id}`.toLowerCase().includes(q)
}
/** The templates matching a query, in gallery order. */
export function searchTemplates(query: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate[] {
return templates.filter((t) => matchTemplate(t, query))
}
/** The template with this id, or null. */
export function templateById(id: string, templates: readonly AgentTemplate[] = AGENT_TEMPLATES): AgentTemplate | null {
return templates.find((t) => t.id === id) ?? null
}
/**
* The builder state a template starts from. Merged over the CURRENT spec so a model
* the user already chose survives picking a template the template owns the prompt
* and the character of the agent, never the model, which is the org's own decision.
*/
export function specFromTemplate(t: AgentTemplate, current: AgentSpec, defaults: AgentConfig): AgentSpec {
return {
...current,
name: t.name,
description: t.summary,
systemPrompt: t.systemPrompt,
tools: current.tools,
config: t.config ? { ...defaults, ...t.config } : undefined,
}
}
+33
View File
@@ -138,6 +138,24 @@ export type AgentBuilderLoaders = {
loadPromptBody?: (name: string) => Promise<string>
/** The live tool catalog. Rejects → typeable-only tools. */
loadTools?: () => Promise<BuilderOption[]>
/**
* Draft a spec from a plain-English description the quickstart's "describe your
* agent" box. A model call, so it is an effect like the rest; the instruction and
* the parse of the answer are pure (`draftInstruction`, `parseDraft`) and shared.
* Absent the quickstart still works: the description seeds the handle and the
* description field, and the user writes the prompt. Rejects the same fallback,
* with the reason shown, so a drafting failure never blocks building an agent.
*/
draftAgent?: (description: string) => Promise<Partial<AgentSpec>>
/**
* Run the agent once (`POST /v1/agents/:ref/run`) and return the RECORDED run.
* The quickstart's third step proving the thing that was just created actually
* answers, which is the only step that can prove it. Absent the step says so and
* points at the endpoint instead of pretending. THIS SPENDS: the backend authorizes
* the org's balance before any inference, so an unfunded org is refused rather than
* given free compute.
*/
runAgent?: (name: string, input: string) => Promise<AgentRunResult>
/**
* Create the agent from the pruned body (`toCreateBody(spec)`). This is the ONE
* mutation it MUST target the unified agent backend (`POST /v1/agents`), which
@@ -147,6 +165,21 @@ export type AgentBuilderLoaders = {
createAgent: (body: AgentCreateBody) => Promise<unknown>
}
/**
* One recorded run, as the quickstart needs it. Deliberately the small half of the
* backend's run view: what happened, which model did it, and what came out. A
* `status` other than `ok` is a run that REALLY failed the backend records the
* failure as a run rather than hiding it so `error` is a fact about the execution,
* not a transport problem to guess at.
*/
export type AgentRunResult = {
status: string
model?: string
output?: string
error?: string
durationMs?: number
}
/** The reason a create failed, distinguished so the UI reacts correctly. */
export type BuilderErrorKind =
/** The `/v1/agents` route isn't bound on this deployment yet (404). */
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { identityTraits } from './Analytics'
import { type Account } from '~/lib/api/types'
const account = (over: Partial<Account> = {}): Account => ({
owner: 'hanzo',
name: 'z',
userId: 'sub-1',
...over,
})
describe('identityTraits', () => {
it('carries the email and the human name off the IAM claims', () => {
expect(
identityTraits(account({ email: 'z@hanzo.ai', displayName: 'Z Hanzo' })),
).toEqual({ email: 'z@hanzo.ai', name: 'Z Hanzo' })
})
it('falls back to the login handle when no display name was claimed', () => {
expect(identityTraits(account({ email: 'z@hanzo.ai' }))).toEqual({
email: 'z@hanzo.ai',
name: 'z',
})
})
// An absent claim must be ABSENT, not `undefined`: a trait sent as undefined
// is a trait written, and it would blank a value an earlier identify had set.
it('omits a key it has no claim for rather than sending undefined', () => {
const traits = identityTraits(account({ displayName: 'Z Hanzo' }))
expect(traits).toEqual({ name: 'Z Hanzo' })
expect('email' in traits).toBe(false)
})
// The tenant is stamped server-side from the validated bearer. A tenant the
// client can name is a tenant the client can get wrong.
it('never sends the org', () => {
const traits = identityTraits(
account({ email: 'z@hanzo.ai', organization: 'hanzo', owner: 'hanzo' }),
)
expect(traits).not.toHaveProperty('org')
expect(traits).not.toHaveProperty('organization')
expect(traits).not.toHaveProperty('owner')
})
})
+1 -1
View File
@@ -20,9 +20,9 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
import { RecordsView, type FieldDefinition } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
import { baseCollectionToFields } from '~/lib/base-data/fields'
import { BackendStateCard, classifyBackend, type BackendState } from '@hanzo/ui/product'
type LoadState =
| { phase: 'loading' }
@@ -21,11 +21,10 @@ import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowLeft, Pencil, Save, Trash2, TriangleAlert, X } from '@hanzogui/lucide-icons-2'
import { RecordDetail, RecordForm, type FieldDefinition } from '@hanzo/data'
import { BackendStateCard, classifyBackend, type BackendState } from '~/components/ui/BackendState'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { BaseDataApi, type BaseRecord } from '~/lib/base-data/api'
import { baseCollectionToFields } from '~/lib/base-data/fields'
import { recordLabel, savePayload } from './records'
import { BackendStateCard, PrimaryButton, classifyBackend, type BackendState } from '@hanzo/ui/product'
export interface RecordDetailViewProps {
/** A configured Base client (transport already wired — e.g. the `/superbase` proxy). */

Some files were not shown because too many files have changed in this diff Show More