16 Commits
Author SHA1 Message Date
zooqueenandHanzo Dev ff7f517985 Release publishes tabs.hanzo.ai to the Sites plane from the forge
Both workflows live in .hanzo/workflows, which git.hanzo.ai runs. Release
installs with pnpm, typechecks, tests, exports, checks the sheet is in the
export, and publishes `out/` through hanzoai/ci's site action as project
`tabs`, the prefix the tabs.hanzo.ai route serves. The deploy key comes
from KMS; no bucket credential is held.

hanzo.yml's gates install with pnpm, since `catalog:` specs only resolve
there, and package-lock.json goes with npm.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-15 11:19:58 -07:00
zooqueenandHanzo Dev 74c97542a0 The export builds, and draws with the component sheet
`next build --webpack` stopped at react-native-svg asking for
@react-native/assets-registry/registry; the web registry lives in
react-native-web, so the alias points there and the gui packages are
transpiled.

The server renders @hanzo/ui through its node entry, which imports no CSS,
so the export shipped without the sheet and the page drew as bare HTML.
The root layout imports @hanzo/ui/styles.css, and the Zen faces from
@hanzo/design, so tokens, themes and component rules reach every page.

The typecheck runs before a build creates next-env.d.ts, so *.css imports
are declared for TypeScript 7. tsconfig carries the two values Next writes
on every build. The install stops on no prompt: both jest build scripts
are answered.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-15 11:19:58 -07:00
zooqueen 31a8b4d6de a surface guard, so the four defects cannot come back quietly
hanzo.yml already promised that "everything that needs a browser is verified
against a real one". Nothing kept it. Four defects were live on two screens and
were found by hand, which means the next four will be too.

The guard builds the export and walks every screen a stranger can reach —
the front page and the sign-in wall — at 390, 768 and 1440, asserting four
things a reader is owed: no sideways scroll, every control at least 44px tall,
every colour pair clearing AA, and no run of prose under 12px.

Three decisions worth keeping:

It serves out/ the way the bucket does, resolving a directory to its index. A
test server that routed differently from hanzoai/static would pass on pages that
404 live.

The contrast reading resolves each element's OWN painted background by walking
up to the nearest ancestor that paints one. Comparing everything to the body's
is wrong in the one direction that matters: dark text on a light button reads as
1:1 against a dark page and reports a failure that is not there. That false
positive is why the reading lives in a named function instead of at each call
site — it was hand-written wrong once already.

The four readings are SOFT. A hard expect throws on the first failure, so a short
tap target hid the type size behind it and the screen would have taken one CI run
per defect to come clean. Verified by putting all four defects back: it reports
tap targets and type size together, at all three widths, naming each element and
its measured size.

A label may be small; eight words may not. That is the line between the 11px
eyebrow, which is fine, and the 11px paragraph, which was not.

Not checked: line breaking. A widowed last line is a measure decision, and a rule
general enough to catch one would flag prose that is fine.
2026-08-17 03:50:35 -07:00
zooqueen 8c85a0fd22 the way back off the sign-in wall is a place a thumb can land
"What is this?" was 342 wide and 15 tall. It is the only escape from a sign-in
screen for someone who arrived not knowing what Tabs is, so it reaches 44 like
the button above it. Same property the footer links just got, same reason.

Measured on the live page at 390: no overflow, no contrast failure, and
"Continue with Hanzo" was already 342x44 — this was the one thing short.
2026-08-17 00:12:56 -07:00
zooqueen 076d0cce57 three measured fixes on the front page
Walked the page at 390, 768 and 1440 and measured rather than looked. Three
things were wrong, all of them one property:

The hero paragraph was max-w-xl — 576px inside a 768px measure — so the sentence
broke with "a browser." alone on a third line at every width above the phone.
max-w-2xl gives it the room the container already had.

The card's closing two sentences were text-xs, which is 11px here. That is a size
for the label above them, not for prose. text-sm.

The two footer links were 51x15 and 127x15. A thumb needs 44, which the buttons
in the hero already ask for with min-h-11, so the links ask for it too.

What was NOT wrong, having checked: no horizontal overflow at any width, the
feature grid collapses correctly, and every colour pair passes AA against the
background it actually sits on — the footer reads dim and is fine.

No visual guard exists in this repo; the suite is unit tests over pane and tile
logic. These were verified by measuring the live page.
2026-08-17 00:09:41 -07:00
zooqueen cc06972089 tabs: a money refusal is an object, and it has a remedy
The fleet answers a refusal two ways. Most doors send `{error:"..."}`; MONEY
sends `{error:{code,message}}` — 402 insufficient_balance, 402
spend_cap_exceeded, 503 balance_unavailable — because every Hanzo surface reads
that one contract by `error.code`. This read only the flat shape, so the nested
object was coerced by `new Error(obj)` and the ONE refusal a person can act on
rendered as `[object Object]` beside a button that had just failed.

It reads both now, and keeps the CODE. That is the part worth keeping: being out
of credit wants a different answer from a full node or an image that will not
pull, and telling them apart by matching the sentence breaks the first time
somebody rewords it.

So the pane says the thing the person can do RIGHT NOW: a cloud machine is ours
and costs credit, one of theirs costs nothing, and `hanzo link` puts it in this
same workspace. No billing link — tabs has no backend and no billing surface, and
the workspace's own gate forbids opening another domain in a tab, which is the
rule that caught the first draft of this and was right to.

Latent until now, because `SANDBOX_FEE_CENTS` is unset and the gate is skipped
whenever the fee is zero, so nothing has ever been refused for money. It would
have surfaced on the first launch after somebody set a price.

The `answer` helper takes a status, so a 402 can be driven rather than described.
2026-08-15 12:24:07 -07:00
zooqueen fcc731f259 tabs: the type size is the reader's, and it reaches every terminal
The terminal is a cross-origin frame, so nothing here could style it — a shell
rendered in whatever the browser calls monospace, at whatever size the page was
built with, and no way to change either. The terminal page now takes a size from
the host that frames it; this is the host saying which.

Two buttons and the number they move, in the action row. A stepper IS the
setting, so there is no panel to open and nothing to find, and it reads its own
state where a slider or a menu would each need a second affordance to do it.

It is stored apart from the layout, under its own key. They answer different
questions: a layout is this workspace's arrangement and a person rearranges it
all day, while type size is how their eyes work and should survive closing every
pane they have.

It is sent on every change AND on every pane that reports ready, which is what
makes one setting reach panes opened later with nothing to remember. `alive` is
that signal and it is already how this file knows a terminal booted — a pane that
never answers is never sent to, which is right: there is nothing there to hear
it.

Verified against the real terminal page in a browser, across origins as the two
hosts really are: the readiness handshake reaches the host, the default lands as
12px, 18 and 9 land live, and at 9 the pane refits from 20 rows to 29.
2026-08-15 12:13:45 -07:00
zooqueen 4b4bf9dfce tabs: a pane names the machine and which of its faces, both numbered
A workspace read `tabs · hanzo`, `tabs-2 · hanzo`, `tabs-2 · shell-2`. Two bare
nouns per pane and neither one told you which box or which shell: `tabs` was the
product that opened the machine, and `hanzo` was the company standing where the
session's name goes.

The shape is now `<machine> · <face>`, and both halves count from one.

Machines are `cloud-1, cloud-2` — what the machine IS, one you shell into —
and `desk-1, desk-2` for the ones with a screen. The first carries its number
like the rest, because `cloud` beside `cloud-2` reads as a different kind of
thing rather than the one before it.

Two stems and not one: machines are counted against those RUNNING, and the
project keys the disk, so a stopped `cloud-2` still owns its home — a desktop
later handed that name would mount a shell machine's disk and call it a screen.

Faces are `shell-1, shell-2` and `desk`. Only NEW shells take the new name; a
pane already open was saved with its binding and comes back on the session it
was on, so nothing running is left behind.
2026-08-15 10:34:01 -07:00
zooqueen 1f41854833 tabs: the chrome was two rows, and the seam was a third thing in the window
The page kept a header of its own — brand on the left, Disconnect on the right —
above the workspace's action row. Two rows for five controls, when the action row
already had an empty middle wide enough for both. It cost every pane about 28px,
and a pane is measured in rows of terminal you can read.

The workspace's row now takes what sits at its ends as `start` and `end`, so the
page still owns its brand and its way out and there is one row on the screen.
Outer padding and the gaps come down with it.

A pane's name bar goes 36px to 28px for the same reason, times the number of panes.

The divider's grabber was a 3px pill on a 12px strip that filled with light on
hover, which drew a third object between two terminals. What it CATCHES and what
it LOOKS like are separate: the hit area stays a thumb wide for touch, the mark
becomes a hairline, and it brightens on hover where the pointer already is.
2026-08-15 10:27:16 -07:00
zooqueen 385fa229d9 A linked machine's terminal is the same signed-in person as the page
The pane framing a linked machine's shell asked you to sign in again. Its
tunnel is gated on hanzo.id, hanzo.id refuses to be framed — correctly, a login
page inside someone else's document is clickjacking — so the gate's redirect
could not run in the frame, and the workspace fell back to opening a second
sign-in in a second tab. For the same person, already signed in, on the same
identity. Per machine, and again every three hours.

The tunnel is now asked directly, with the token this page already reads the
registry with, and answers with the session the redirect would have set. What
that removes is a whole shape: a pane URL is MINTED for every machine now,
never derived, so the workspace no longer knows whether a pane is a sandbox we
started or a machine you linked — they differ only in who serves the page and
what credential opens it, and neither is a layout's business.

The rescue collapses with it. One card, one button, for any credential that
stopped working: ask for another. A mint that never lands offers it too, rather
than leaving a pane waiting on a frame that was never made.
2026-08-11 10:29:11 -07:00
zooqueen 47e6e3983d tabs: the CI file said this repo ships nothing, and it ships the site
release.yml syncs the export to the tabs bucket on every push to main. The
header above claimed the opposite — 'published as a package rather than
deployed from this repo' — which reads as 'there is no lane here' and sends
the next reader looking for one.
2026-08-11 10:18:33 -07:00
zooqueen 4fd4a9dfc4 tabs: the pane opens on the click, not on the machine
Starting a cloud machine took tens of seconds and drew nothing for all of
them: `launch` awaited `onLaunch` before it called `open`, so the only sign
the click had landed was a word change on the button that caused it. The
workspace itself sat unchanged, which reads as a dead button.

The pane is opened by the CLICK and settled by the ANSWER. A binding can now
say `starting` — a machine is being made for this box, and here is the loader
— and `failed`, which keeps the reason in the space the machine was for.
`open` answers with the pane's id so `launch` can settle that one box, and
`settle` no-ops on a pane that was closed while its machine was coming: an
answer arriving late must not reopen a box someone shut.

Neither state survives a reload. The promise died with the page, so a restored
`starting` would spin for a machine nobody is waiting on and a restored
`failed` would state a reason about a moment that has passed; `restore` brings
both back as the pane that ASKS, by which time the machine may well be in the
list. `gone` still survives — tmux is holding that session, so that pane is
still about something.

With the wait and the failure both in the pane, the button needs neither, so
`Launch` collapses into the plain button `New shell` already was and all four
share it.
2026-08-11 10:10:45 -07:00
zooqueen e67bc8cfa4 tabs: a machine you can watch, not only type into
A pane shows a machine. Until now there was one way to look at one — a
shell — so the binding that says WHAT a pane shows only ever said which
tmux session. A desktop sandbox has an X server, and nothing here could
show it.

So a binding has a second face. `{kind:'screen', machine}` carries no
name because a machine has one display where it has as many shells as you
ask for, and the machine's own class decides whether it is offered at all
— `desktop` runs an X server, the other classes do not, and a name is
never read as a capability.

ONE MINT FOR BOTH DOORS. The ticket answers the address it opens, so
`frameUrl` asks for a door and frames what the server named, rather than
composing a URL a client could spell wrong. The bearer still never leaves
lib/api; what a pane holds is the minted URL.

One button, one intention: Desktop opens a screen where a desktop is
running and starts one where none is. A disabled button beside a second
button that makes it work would be two controls for one thought.
2026-08-11 00:26:42 -07:00
zooqueen 4a09925faa tabs: a cloud machine is a sandbox
New cloud machine ordered a DigitalOcean droplet through visor, waited a
minute for it to boot and run `hanzo link`, and — with no name in the body —
answered 422 "Droplet must have a name", so in production it ordered nothing
at all.

It leases a dev sandbox now. POST /v1/sandboxes returns when the pod is
RUNNING, so the machine and the news of it arrive together, and the button
opens its first shell rather than adding a row to a list. The project counts
up against what is already live, because the server keeps one live sandbox
per project — which is what makes a second press a second machine instead of
a second attempt at the first.

That leaves one plane where there were two: visor's launch, list, and the
merge that held a place for a box still booting all go, along with the
pending statuses that existed only for that gap. A failed start now carries
the server's own reason to the button that caused it.
2026-08-10 21:45:14 -07:00
zooqueenandHanzo Dev 83789c921b tabs: the assistant lives bottom right, like everywhere
console.hanzo.ai and hanzo.app keep Hanzo in the bottom-right corner; tabs
had no assistant affordance at all. Tabs is deliberately backendless, so
this is the doorway (hanzo.chat, new tab), not the room — same corner, same
glyph, one place to look on every surface.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-08 17:21:36 -07:00
zooqueenandHanzo Dev 35c7194fc2 tabs: sandboxes are machines — the builder's pods join the workspace
Third machine source beside linked tunnels and visor boxes: dev sandboxes,
framed through the one terminal contract every Hanzo surface uses
(/v1/sandboxes/:id/terminal, single-use thirty-second ticket, ?arg= names
the tmux session exactly as ttyd's --url-arg does). A sandbox host carries
an id instead of a published base; the workspace mints the URL when a pane
binds — once per pane, because the frame's first load spends the ticket —
and a refused pane offers Reconnect (a fresh mint), never the sign-in tab,
which for a spent ticket would open a dead door.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-08 16:29:54 -07:00