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>
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.
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.
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.
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>
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.
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>
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.
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.
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>
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.
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.
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).
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.
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.
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.
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>
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.
Three defects found by a rendered-DOM audit (CDP + hit-testing) of the live
cloud.hanzo.ai at 390x844 and 1440x900. Measured before AND after, per defect.
FOOTER legal links were CLIPPED off-screen at 390px. The link clusters are Views
(`flex-shrink: 0`), so they held max-content width and their own `flex-wrap` never
engaged: Terms painted at x 397->435 on a 390px viewport, while
`html,body{overflow-x:clip}` keeps `documentElement.scrollWidth` at 390 — the
overflow is CLIPPED, not scrollable, so a legally-required link could not be
reached by any gesture. `ConsoleFooter`'s `flexShrink` already fixes that in this
line; production is BEHIND it (live still renders the hero as a SPAN, so it
predates the same commit), so rather than re-fix it this locks the geometry: at
390 the row wraps to two lines, Terms lands at x 149->187, every link hit-tests
to itself, and nothing on the page is painted past the right edge.
HEADER chrome rendered in a SYSTEM font while the body rendered Geist.
`@hanzogui/shell` sets its own stack as an INLINE style on its root
(`fontFamily: CHROME.font` = `ui-sans-serif, system-ui, -apple-system, "Segoe UI",
…`, which names no Geist) and its subtree inherits it — its buttons re-declare
`font-family: inherit`. Live: wordmark `Noto Sans:11:SYSTEM`, nav
`Noto Sans:9:SYSTEM`, hero `Geist:26:custom` — mixed typography on one screen.
Geist loads fine (self-hosted woff2), so this is a CASCADE problem and the font
loading is untouched. One rule in globals.css pins `[data-hanzo-shell]` and its
descendants to `var(--font-sans)`; `!important` is required because nothing else
beats an inline declaration, and `code/pre/kbd/samp` keep the mono face so the two
font invariants stay orthogonal. After: nav `Geist:9:custom`, Meet-Hanzo
`Geist:10:custom`, CTA `Geist:7:custom` — the body's own face. Deleting ONLY that
rule from the CSSOM on the same build reverts the header to `Noto Sans:9:SYSTEM`
with the old stack, so the rule is demonstrably the fix, in isolation.
The desktop logged-out header carried TWO "Sign in" affordances. `HanzoHeader`
renders its OWN account link whenever `account` is nullish
(`account ?? <DefaultAccount/>`) and `landingSurface` already relabels the primary
CTA "Sign in", so live read `[Get API key] [Sign in -> /signin] [Sign in ->
href="#"]` — the duplicate was also a dead link. `PublicLanding` now declines the
control explicitly (`account={false}`: not nullish, so the default never renders,
and React draws nothing — including the mobile sheet's identity row).
Also: `@hanzogui/shell` was pinned `^7.6.4`, which is not published (latest is
7.6.3) — `npm install`, which the Dockerfile runs, fails ETARGET on it, so no
image could build. Relaxed to `^7.6.3`, which still admits 7.6.4 the moment it
publishes; this tree compiles and passes against 7.6.3.
Verification: `next build` ✓ ("Compiled successfully", types + 20/20 static
pages); `tsc --noEmit` clean; `vitest` 3024/3024; `e2e/landing-chrome` 3/3 against
the PRODUCTION build on `next start`. Font evidence is CDP
`CSS.getPlatformFontsForNode` (real family + custom-vs-system) — never
`document.fonts.check()`, which answers true on a page with zero @font-face rules.
platform.hanzo.ai now lands on a REAL deploy platform, not the generic catalog:
- App Store product (store, Platform): browses the LIVE 1000+-app templates.hanzo.ai
catalog fetched straight from the browser (open CORS, no BFF -> works in go:embed);
search-first + Load-more (DOM capped), monogram logo fallback.
- One-click deploy reuses the console's REAL PaaS path (PaasApi -> /v1/platform/*):
ensure project -> createApp{source:git} -> deploy; honest build/live states. No new
backend.
- Maker Earn-20% hook -> in-console /authors (parsed from links.github).
- PlatformHome: deploy hero + tiles (App Store/Containers/Functions/Usage) + featured
OSS strip + your projects; PlatformModule '' renders it.
- Home Deploy-OSS tile -> native /store (was an external link-out).
- Additive only: the committed single-product platform shell is untouched.
tsc clean; vitest +24 green; next build + build:embed green; render-proven
(e2e/platform-store.spec.ts).
Add a first-class Webhooks product (category Dev) over the live /v1/webhooks API,
mirroring the VpcModule/LoadBalancerModule idiom (restGet/restPost/restPatch/
restDelete + cloudProxyV1Url, PlatformError handling, enc() id-escaping):
- List: endpoints table — url, event chips, active/disabled status, 7d
deliveries/failures usage (when present), row actions.
- Create: HTTPS url + comma/pattern events (* / commerce.> / agent.run.*) +
description; reveal-once signing secret in a copyable callout.
- Enable/disable: inline PATCH {status} toggle.
- Security: rotate secret (confirm → reveal-once) + the X-Webhook-Signature
HMAC-SHA256 scheme shown inline.
- Test: per-endpoint sync test-send, inline delivered/httpStatus/durationMs result
(works while disabled).
- Logs: per-endpoint deliveries (expand + :view deep-link), newest-first, failed-
only filter, manual refresh.
Sub-features (test/deliveries/rotate/usage counters) still landing from the cloud
lane DEGRADE GRACEFULLY — a 404 hides that one affordance, never an error card.
Register one CatalogEntry (routes '' + :view); the shell/router derive from the
catalog. Add the `webhooks` head to proxy-allow CLOUD_HEADS so the standalone /v1
bearer proxy forwards /v1/webhooks[/:id/{deliveries,test,rotate-secret}] (org from
the token owner); the go:embed console hits cloud /v1/webhooks natively. Retire the
workbench Webhooks stub to a read-only glance that deep-links into the product (one
place owns CRUD). Add a mocked-network Playwright render spec.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Follows the cloud endpoint rename (/v1/admin/storage → /v1/admin/block-storage,
cloud 9a51bffbc) so /v1/admin/storage stays free for the operator's S3
object-buckets view. Client (storage-fleet.ts) + the ADMIN_AGGREGATE_HEADS /
ADMIN_V1_HEADS allow-lists + the e2e mock all move to the block-storage head; the
registry entry id was already block-storage. The real admin.hanzo.ai Block Storage
view lives in hanzoai/admin apps/operator (this console board is the super-admin
twin on console.hanzo.ai).
NOTE: main is RED from a PRE-EXISTING unrelated error — src/lib/event.ts:55 passes
`ingestKey` to createAnalytics but the installed @hanzo/event AnalyticsConfig has no
such field (another agent's 8.4.152 analytics work; needs a @hanzo/event bump).
This repoint is green on its own; the event.ts RED blocks the shared build:embed
gate until that lane fixes it.
The admin.hanzo.ai realtime block-storage view, so we can watch the analytics
datastore fill and scale DO storage before it runs out. One read:
StorageFleetApi.snapshot() -> GET /v1/admin/storage (the global-admin-gated
aggregate; storage added to ADMIN_AGGREGATE_HEADS + ADMIN_V1_HEADS).
- StorageFleetModule (Observe, admin:true): fleet KPIs (volumes / provisioned /
used / monthly $), the analytics datastore highlighted with a green/amber/red
fill bar + near-full badge, near-full alerts, and the full volume list.
- Honest by construction: DO gives capacity + attachment but NOT fill %, so a
volume's used/pct render an em-dash "—", never a fabricated number; the
datastore card shows only when a filesystem source (system.disks) reported fill.
- e2e (storage-fleet.spec.ts): renders the datastore (200 GiB), fleet KPIs
(295 volumes / $1,309), a 91% near-full alert, and the honest "—" — passes.
Ships to admin.hanzo.ai via the next hanzoai/cloud release embedding console@main.
Pairs with cloud GET /v1/admin/storage (DO volume inventory + system.disks fill).
Renders every admin-only view as super-admin (primeSession owner:admin) against a
mocked local server and asserts: monochrome in dark AND forced-light (the hue-220
fix — no blue cast), org search reachable, and all 28 admin views render their
shell without an error-boundary/pageerror. Screenshot per view (e2e-shots/admin-audit).
All green locally.
- gpus-connect: BYO GB10 surfaces via /v1/fleet/workers (not /v1/machines, which
excludes provider=byo); assert Connect+Deploy CTAs + the 'hanzo gpu connect' drawer.
- entitlement-sidebar: assert the real gating contract (enabled shown / non-entitled
hidden / catalog affordance present+enabled); the AddProductPanel DetailPane is a
separate concern. Both PASS locally.
Collapse the six-deep AuthGate → WaitlistGate → OrgGate → ScopeProvider → … →
OnboardingGate → DashboardShell chain into ONE pure resolver, one flat switch, and a
flat provider list. The whole entry decision is now a VALUE, rendered once.
- src/entry/resolve.ts: pure resolve(session) → signin|waitlist|org|onboard|ready. No JSX,
unit-tested (resolve.test.ts). Fail-closed lives here: `ready` (the app + its data) is
reachable ONLY for a loaded, authenticated, org-ENTERED session — proven by exhaustive
sweeps that resolve NEVER yields ready for a loading/anon/org-less session.
- src/entry/entry.tsx: gathers the session with hooks, resolves ONE stage, renders exactly
one surface via a flat switch.
- Self-contained stage views (no cross-imports): auth.tsx, waitlist.tsx, scope.tsx,
onboard.tsx, dashboard.tsx.
- src/entry/providers.tsx: the ready-only app-shell context as ONE flat ordered list
(reduceRight), mounted only at `ready`. Preferences + Toast (read by the resolver / the
onboard wizard / every module) sit above the switch.
- Drop compound suffixes (hooks unchanged): AuthGate→Auth, WaitlistGate→Waitlist,
OrgGate+ScopeProvider→Scope, OnboardingGate→Onboard, DashboardShell→Dashboard,
AppLauncherProvider→Launcher, PreferencesProvider→Preferences, CommandPaletteProvider→Palette,
FloatingChatProvider→Chat, ToastProvider→Toast, DetailPaneProvider→DetailPane.
- Delete the dead gate components; sweep stale names from comments. Same auth/waitlist/org/
onboard semantics + white-label brand-per-host intact — structure + names only.
tsc --noEmit clean; vitest 2868/2868; next build ✓ (compiled + type-checked).
Backend (hanzoai/ai) renamed the router-config routes to resource-oriented
nouns and DROPPED the old compound routes (no aliases, no backwards compat).
Move every console caller in lockstep so nothing 404s after ship:
GET /v1/get-router-policy -> GET /v1/router/policy
POST /v1/update-router-policy -> PUT /v1/router/policy (verb -> PUT)
GET /v1/get-routing-defaults -> GET /v1/router/defaults
GET /v1/get-org-settings -> GET /v1/org/settings
POST /v1/update-org-settings -> PUT /v1/org/settings (upsert, PATCH-merge)
POST /v1/delete-org-settings -> DELETE /v1/org/settings
GET /v1/get-org-settings-list -> GET /v1/org/settings/list
- api/router.ts: RouterPolicyApi.get->originGet('router/policy'),
save->originPut('router/policy') (PUT, not POST).
- api/org-settings.ts + api/org-blend.ts: originGet('org/settings'),
originPut/originDelete; the read-modify-write still sends the full row
(safe under the backend's new PATCH-merge PUT).
- next.config.mjs: drop the dead get-/update- heads; 'router' head already
covers /v1/router/*; TARGETED /v1/org/settings* rewrites (an 'org' head would
hijack platform /v1/org/{org}/cluster).
- app/ai/[...path] ALLOWED: swap old exact paths for router/policy +
org/settings + org/settings/list.
- ai-accounts/routing-defaults route: UPSTREAM_PATH -> v1/router/defaults.
- e2e/router-config.spec.ts: GET+PUT dispatched on the one /v1/router/policy
noun; models-surfaces mock -> /v1/org/settings. Doc/hint sweep.
tsc --noEmit clean; next build + build:embed green.
Extend the Router > Policy editor (RouterPolicyEditor) with the two org-router
controls the auto-router (Enso) needs, round-tripped through the existing
GET/POST /v1/{get,update}-router-policy:
- Enabled models: a checklist populated from the policy's servable `available`
set (shows name, submits id); Select all / Clear; empty selection = ALL models
allowed (labelled clearly).
- Savings<->quality dial: a labeled 0..1 slider (step 0.05) via FieldSlider;
0 = savings, 1 = quality, 0.5 = balanced; null/unset renders balanced.
Preserves prefer + costCeiling on save. RouterPolicy gains enabledModels /
qualityBias / available (RouterModel). The auto-routing ON/OFF toggle stays a
separate concern on the Smart-routing (ai-accounts) surface — not duplicated.
Playwright-verified (e2e/router-config.spec.ts, 3/3): panel renders, Select-all/
Clear re-count live, Save POSTs enabledModels + qualityBias (contract proof),
mobile no horizontal scroll. tsc clean; vitest 2751 pass; next build + build:embed green.
Extends the existing models product with two tabs rather than adding a second
catalog. The Catalog tab gains a vision capability badge derived from the
catalog's own features, both sides of the per-Mtok price, and the published
benchmark headline. Leaderboard ranks the enso-bench prior corpus by any
benchmark it covers. Blend lets an org choose the models its router runs over
and shows the Enso flash, blend and ultra tiers re-forming as that set changes.
Benchmark scores are a checked-in fixture regenerated from hanzoai/enso-bench by
scripts/sync-benchmarks.mjs and imported at build time, because the corpus is a
versioned artefact rather than live state: it changes when a bench run lands, not
per request, so an endpoint would buy nothing and cost a loading state, a failure
mode and a fabrication risk on every page view. Every score keeps its source, our
own harness is badged, a model with no published score renders an em-dash rather
than a zero, and a model unscored on the selected benchmark is omitted from the
ranking rather than ranked last at zero.
The blend rules are a port of the reference semantics in enso-bench arms.py
resolve_blend, so the console means exactly what the router means by enabled.
Alongside the hand-written semantics tests there is a parity suite that executes
arms.py and diffs its real output; it caught a genuine divergence, since Python's
stable sort preserves catalog order for equally priced models and an id tie-break
in the port silently reordered them. That suite skips cleanly when the enso-bench
checkout is absent so CI never fails on a missing sibling repo.
Blend persistence rides the org's existing OrgSettings row rather than inventing
an endpoint. The gateway does not yet carry the three model columns, named in a
TODO on the client, so the write is attempted for real and then re-read to
confirm it survived; when it did not the board says so plainly instead of
confirming a write the backend discarded.
Vendor logos reuse the existing self-contained ProviderLogo marks with a monogram
fallback, with no external requests. The blend row's vendor label now resolves
identity-first through the same resolver the avatar uses, so a gateway-served
model reads as its true vendor rather than showing Zhipu or Moonshot artwork
beside the word Zen.
Verified with typecheck, the unit suite, both builds including the go:embed gate,
and a Playwright spec that drives all three surfaces in a browser.
Turn the console CD product into a mobile-first deployment map wired to the
native cloud CD projection (cd.hanzo.ai's surface).
- FIX the #1 blocker: repoint the client from the never-bound /v1/gitops to
cloud's /v1/deploy, and REMAP the real clients/deploy DTOs into the console
view-models (repository/version->image, runningVersion->liveTag,
healthMessage->message, parentRefs[].ref->ownerRefs, object manifests->JSON,
logs blob->lines). Add the deploy head to the proxy allow-list.
- RENDER the fleet as a @hanzo/canvas ProjectCanvas map: foldFleet folds each
App CR into a service node (CD health status, git/image source, capability,
deploy time), a deterministic grid layout (no invented edges), env switcher,
search, and click-to-filter KPI tiles.
- ENRICH nodes best-effort in parallel: git repo+branch (GitApi.repos) and CI
build time (BuildsApi); missing enrichment never drops a node.
- DRILL-IN drawer (ServiceDetailChar tabs): Resources = the owned-resource
topology via treeToGraph, Deploys = the CI build timeline, Logs = live pod
logs, Source = git repo/branch/commit + image; confirm-gated Sync + Rollback
(rollback offers only real clean-semver git releases).
- MOBILE-first: full-screen drawer, touch pan/zoom, no horizontal body scroll,
nav collapses to the hamburger.
- Mount the shared @hanzo/canvas primitive (the base @hanzo/gitops wraps);
delete the dead parts.tsx + ui-contract.ts mount-seam.
- Tests: 52 (client DTO mapping + fold/grid/release-targets) + a responsive
Playwright e2e (desktop 1440 + mobile 390, screenshots, no-overflow +
nav-collapse asserts).
- Land the comprehensive billing/invoices/usage/o11y render smoke
(billing-usage-o11y.spec.ts: every billing sub-page, invoice view/download/
statement/reload, settings, usage/metrics/AI-metrics, the full o11y set, and a
dead-card audit) — the E2E agent authored it but never committed before the
session limit.
- console.spec + live-billing-admin: assert the TRUE "no data tunnel" invariant
(an unauthenticated request never gets a 2xx carrying backend JSON; a SPA-HTML
fallback and a >=401 gate both pass) instead of brittle exact status codes on
renamed/pruned paths (superbase->base, the go:embed-pruned /admin/aggregate).
theme-color #0a0a0a -> #000000 (live value).
- Shared fixture-server gate (_fixture.ts): the localhost:4000 render specs
(ai-economics/budgets/gpus/provider-billing(A)/entitlement-sidebar/
interactive-training/blank-audit) skip cleanly when that server is unreachable
instead of ECONNREFUSED-failing against prod.
- probe-o11y skips without HANZO_PASSWORD instead of hard-throwing.
- playwright retries:2 in CI to absorb Tamagui/RNW SPA-hydration render flakiness
(a real regression fails every attempt, so nothing is masked).
Full live run vs console.hanzo.ai: 15 passed / 299 skipped-cleanly / 0 failed.
Prod posture verified live: /v1/admin/* -> 403 JSON (fail-closed), no data tunnels
(superbase/keys/aggregate all SPA-HTML, never backend JSON).
Per the privilege-separation: z@ via console resolves to hanzo/z (non-admin), so
the credit gate refuses it. The funding spec now signs in through the admin.hanzo.ai
surface (admin-guard → owner==admin) and POSTs the credit + balance read there — the
only identity the gate admits. Robust login selectors for the hanzo.id portal form.
Live Playwright E2E: z@ signs in, credits the maxpower org via
/v1/admin/customers/:org/credit, and asserts the balance moves by exactly the
grant; a second spec verifies the funded member (davelorenzini) reaches /platform
with no dead "Could not load". Secrets come from env (HANZO_PASSWORD/DAVE_PASSWORD)
— never hardcoded; the credentialed specs skip without them, so CI stays green.
Surfaced a defect: unauthenticated credit returns 500, not 403 (core.Guard's
*zip.HTTPError 403 is re-wrapped as a generic api-error 500) — which is why the
console renders "Could not load" instead of an auth state. The fail-closed test
asserts rejection (no money moves) and flags the code for a follow-up fix.
GPUs — SEE your connected machines. The BYO connect fleet (`hanzo gpu connect`
boxes: home lab dbc/evo/spark) registers with a per-box heartbeat that ONLY
`GET /v1/fleet/workers` carries (`/v1/machines` folds them in without it,
`/v1/gpus` expands per-accelerator without it). New `FleetApi` (lib/api/fleet.ts)
reads it over the same-origin `/v1` bearer BFF (`fleet` allow-listed in
proxy-allow.ts; direct on the go:embed console), and a reused `ConnectedMachines`
section on the customer GPUs Overview + GPUs tab lists each box: name, accelerator
(arch), memory, online/offline (server-derived at a 90s heartbeat TTL), last
heartbeat, and a "Serving" badge when it runs hanzo-engine. Cloud GPU VMs
(`/v1/machines`, non-BYO) stay a separate list so a box is shown ONCE (DRY).
Honest states throughout; nothing fabricated.
Wallet — the sidebar chip shows the org balance (`/v1/billing/balance`, unchanged)
and "Top up" now LINKS to the brand's hosted payment page in a new tab. New
`config.payUrl` derives `pay.<brand>` from the brand billing host (white-label-safe:
a Lux console links to pay.lux.cloud, never pay.hanzo.ai). Display + link only — the
console hosts no card form and mints no credit.
Mobile — the section reuses the shared DataTable (scrolls inside its own overflow-x
box, never the page body) + flexWrap stat cards, the codebase's one responsive
mechanism. Verified live at 390x844 and 768x1024 (no horizontal body scroll).
Tests: +8 fleet (normalizer/helpers/route), +1 config payUrl (white-label), +3
responsive e2e (desktop render + phone/tablet no-overflow). Full suite 2544 green;
tsc clean; next build + build:embed (go:embed gate) green.
Co-authored-by: Hanzo AI <ai@hanzo.ai>
New admin.hanzo.ai module (`admin: true`, category AI) answering the KEY
question "how many requests hit each model" plus unit economics and the
eval→training flywheel. Composes the existing admin reads — never forks them:
- Model mix: requests / share% / tokens / cost per (provider, model), folded
from /v1/admin/usage/funding over a 24h/7d/30d window (share donut + table +
totals row).
- Profitability: upstream cost vs revenue vs gross margin + runway
(/v1/admin/finance) and per-provider credit (/v1/admin/providers/credit).
- Training data: the HONEST collection card — the metering ledger (datastore)
holds no prompt/completion content and nothing harvests traffic; the only
training data is the user-curated eval dataset registry (live counts).
- Evals: recent LLM-as-judge runs (dataset, evaluator, score, when).
- Router loop: how eval scores fold into the enso router (offline ridge +
online LinUCB), with the honest "per-request reward not yet persisted".
Pure rollups in src/lib/api/ai-economics.ts (foldModelMix / topModelShares /
datasetStats / marginTone …) with 19 vitest cases; one route-mocked Playwright
spec (fable-5 75% mix, 62% margin, honest training card, fail-closed gate).
v8.4.135
Add the Interactive tab to Fine-tuning: a live LoRA client you create on a
base model, drive with forward_backward + optim_step (plotting the real loss
curve), sample from, and export a PEFT adapter.
TrainingApi hits the clean /v1-first `/v1/training/*` (next.config dispatches
the `training` head to the keyless /ai bearer proxy; the per-client
id/forward_backward/optim_step/sample/save_weights sub-paths are allow-listed,
with a DELETE handler). Engine 400/404/409 plain-text bodies surface verbatim.
Tolerant normalizers, vitest + a mocked Playwright e2e. v8.4.134.
The customer GPUs page now makes both paths to add a GPU explicit and equal:
- "Connect GPU" opens a drawer with the ready-to-copy `hanzo login && hanzo gpu
connect` (+ `--serve-engine` to also serve models) and the Desktop toggle note.
- "Deploy GPU" is the existing Visor/DOKS launch flow (relabeled from "Launch GPU").
Machine rows gain a monochrome Source badge distinguishing BYO (provider=byo) from
Cloud accelerators; the empty state offers both actions.
- gpus/ConnectGpuDrawer.tsx: the BYO connect drawer (copy-to-run commands).
- gpus/CustomerGpus.tsx: Connect+Deploy header actions, ProviderBadge, Source column.
- e2e/gpus-connect.spec.ts: mocked-network screenshot proof (BYO GB10 + cloud H100).
The go:embed'd console serves the SPA shell (the / route's index.html) for every
path — verified live: GET / and GET /signin return byte-identical HTML. So a direct
/signin load mounts the dashboard tree (AuthGate), not the /signin route. AuthGate saw
no account and called router.replace('/signin'), a no-op at /signin, and spun on the
loader forever (inputs=0, buttons=0). Reaching /signin as a redirect target (from /,
/projects, ...) worked because the URL changed.
Extract the sign-in experience into one <SignIn/> component (tenant form / admin silent
SSO / redirect-to-/ when authed) rendered by BOTH the /signin route and AuthGate: at
/signin AuthGate defers to <SignIn/> instead of a no-op redirect, so /signin resolves to
the form without depending on a navigation. Add an e2e regression that hard-loads /signin
and asserts the form renders.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>