The shell renders on every Hanzo property, so a word it draws as bare English stays English on every page of every locale, whatever the host translated around it. Each visible string now passes `t()` from @hanzo/i18n/react: the palettes, both headers, the menus, the footer, the frame, the account menus, the meter, notifications, the visitor chat and the launcher. Copy written as data (the registry, the launcher's apps, the account rows) is marked with `phrase()` where it is written and drawn through `t()`. A product's name standing alone is not marked, and host data (palette commands, frame sections, names, orgs) is drawn as given. The palettes translate their rows before ranking them, so a reader searches the words they see. A sentence with a value or a key cap in it is one message with a placeholder; the meter's span is a sentence per span. The send arrow mirrors under RTL. @hanzo/i18n is a required peer (>=0.4.0) so the host's one provider reaches the shell. With no provider `t` answers the English source: 32 server renders of the components are byte-identical before and after. The one markup change is the open launcher's "⌘K to switch", now one sentence around its key cap. i18n.test.mjs parses the source with oxc and fails on copy that reaches the screen without t() or phrase(). Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
10 KiB
@hanzo/gui — notes for whoever is here next
Releasing: version here, publish in CI
The train (every public package, @hanzo/gui among them) publishes from
.github/workflows/publish.yml on GitHub, never from a laptop:
bun ./scripts/release.ts --minor --skip-publish(or--patch) on main writes the next version into every package.json, runs the checks and tests it is not told to skip, commitsv<version>, tags it and pushes.- That push runs Publish on a cluster runner (
runs-on: [linux-amd64], served by the hanzo-platform app). It asks npm whether@hanzo/gui's version on disk is ahead oflatest; if so it installs, builds, checks, lints, typechecks, readsNPM_TOKENfrom KMS (org secretsKMS_CLIENT_ID/KMS_CLIENT_SECRET,POST /v1/kms/auth/loginthenGET /v1/kms/secrets/NPM_TOKEN?env=prod, masked) and publishes withrelease.ts --republish --ci, the one script that rewritesworkspace:*. Any other push answers "not ahead" in seconds. - A dispatch at the version npm already serves finishes a train that stopped part way; release.ts skips every name@version already on npm.
GitHub Actions is ON for this repo for that reason (it was off, and the
publish-gui-all.yml lane it replaced never ran). The same push also runs
cicd.yml, the hanzo.yml checks. hanzoai/i18n and hanzoai/design publish the
same way.
A targeted publish (--only) currently dies in the test gate
bun ./scripts/release.ts --only @hanzo/gui fails before it publishes:
@hanzogui/kitchen-sink#test -> 9 failed, 576 passed, 19 skipped
[webkit] tests/RemoveScroll.test.tsx (scroll position restored after close,
scroll lock prevents body scroll, edge cases, mobile touch device)
These predate the change you are trying to ship. They are webkit-only
scroll-restoration failures in apps/kitchen-sink, and that file was last
touched by 95c64deb76 transplant onto tamagui v2.6.0. Nothing in pkgs/ui
reaches them.
So a one-file fix to, say, pkgs/ui/hanzogui/css-check.mjs cannot pass the gate
on its own merits, and the honest options are:
- let the fleet release carry it (this is what happened for 8.1.0 — a normal
release from
mainpicked up a pending css-check fix and shipped it), or --skip-test, having first confirmed your change cannot reach kitchen-sink, or- fix the webkit failures, which is a real piece of work and nobody's yet.
Do not read a green --only run as "tests passed" — the script's exit code is
easy to mask behind a shell pipeline. Check the log for the turbo summary.
pkgs/ui/hanzogui/css-check.mjs ships as the gui-css-check bin
It is a real published surface, not a dev script: files[] includes it and
bin points at it. Its DEFAULT_ALLOW list names the classes @hanzo/ui
stamps as identity markers (btn, btn-*, badge, badge-*) and it is
ENUMERATED, not wildcarded — btn- is a name anyone might type, so a glob would
wave through a hand-written btn-cta that has no rule behind it, which is the
exact failure the checker exists to catch. When @hanzo/ui adds a Button or
Badge variant, add it here too; drift fails loudly rather than silently.
HanzoFooter (pkgs/ui/chrome) is deprecated
SiteFooter in @hanzo/ui/product is the one footer. It renders the FOOTER
model from @hanzo/products, so its links are shared data instead of a
per-caller prop. HanzoFooter is NOT a re-export of it, deliberately: this
package lives inside @hanzo/gui and @hanzo/ui peer-depends on @hanzo/gui,
so re-exporting would close a gui -> ui -> gui cycle. Its 8 callers keep
working and migrate on their own schedule.
Every package builds with hanzogui-build and ships src + esm + cjs + types
pkgs/build/tsgo-build.mjs is the one build: three tsgo passes per package
(tsconfig.esm.json typechecks and writes dist/esm + types/, the CJS and
native passes run --noCheck and only translate). A x.cjs.ts sibling takes
x's name in dist/cjs, a x.native.ts sibling in dist/native, and neither
reaches any other emit. A module that needs its own location says
import.meta.url in src/here.ts and pathToFileURL(__filename).href in
src/here.cjs.ts; everything else imports url from ./here.ts and names the
createRequire alias need, never require. After the CJS pass every
dist/cjs/*.js is parsed as a script and a file with import.meta, a top-level
await, or a declaration of require/module/exports/__filename/__dirname
fails the build by name. Manifests all state the same shape: types,
react-native (dist/native where the package builds native), browser,
module, import, require, default; main → dist/cjs, module →
dist/esm, files = src, types, dist. Named ESM imports from CommonJS packages
(fast-glob, fs-extra) fail under Node's lexer: use the default import or
fs-extra/esm.
One site, no image
hanzo.yml declares one site: lane (slug gui); the reusable publishes it to
the Sites plane, routed by universe static-sites.yaml (gui-static). The docs
app's export script is build:web (one build), and the turbo task build:web
depends on ^build and the package's own build. There is no Dockerfile and no
image lane. The native Team app that used to live at apps/team is its own repo,
hanzo-apps/team, with its own site lane.
Right-to-left: name sides logically
Hosts set <html dir="rtl"> (via @hanzo/i18n) and every surface renders through
this repo, so a side named physically is a bug under Arabic, Persian, Hebrew and
Urdu. Flex rows and grid columns follow dir on their own; offsets do not.
- Write
marginInlineStart/End,paddingInlineStart/End,insetInlineStart/End,borderInlineStart*/End*,borderStartStartRadius… andtextAlign: 'start'. On web they emit CSS logical properties; on native they land on RN'smarginStart/start/… andtextAlignstart/end becomes left/right, which RN swaps under RTL itself. - RN's own names (
paddingStart,start,borderTopStartRadius) used to emitpadding-startandstart:on web — not CSS, silently dropped.expandStylemaps them to the logical property now. - Inline logical longhands carry the doubled class (
cssShorthandLonghands), sopaddingInlineEndbeats thepadding-rightthatpadding/paddingHorizontalexpand to whatever order the sheet inserted them. A host's PHYSICAL override of a logical side loses the same way: migrate the host, do not re-physicalise. useDirection()reads<html dir>when no provider or prop says otherwise, and re-renders when it changes. Slider, tabs, menus, roving focus, switch and progress follow it. Native still falls back to 'ltr'.- A mark that points along the line (back, forward, send, a drill-in chevron)
spreads
mirror(data-i18n-mirror); gui's base sheet and the shell's state[data-i18n-mirror]:dir(rtl){transform:scaleX(-1)}, the same declaration @hanzo/i18n's rtl.css uses, so several on one page mirror once. Never on logos, media controls, numbers, code or a chart's time axis. - floating-ui already aligns
bottom-startto the inline start under RTL; never pick-endfor RTL yourself (Menu did, and flipped it back to the left). Side placements (left/right) are physical and do need the swap.
The shell's copy is @hanzo/i18n's
Every string @hanzogui/shell draws passes t() from @hanzo/i18n/react
(useI18n), a required peer so the host's one I18nProvider reaches it; with
no provider t answers the English source. Copy written as data — the
registry, the launcher's apps, a row table — is phrase()-marked where it is
written and drawn through t(); a helper that cannot call a hook takes t
(Say) or hands back a marked source (askLabel returns the sentence and its
value for t(...askLabel(question))). A product's name standing alone (Hanzo
Chat, Enso, SDKs) is not marked. Host data is drawn as given: palette
commands, Frame sections, names, orgs, find.label. The palettes search
what they draw, so their rows are translated before search ranks them.
i18n.test.mjs parses the source with oxc and fails on copy that reaches the
screen without either.
tracks and Wrap: the stack sizes its children
tracks is a View prop (core, pkgs/core/web/src/helpers/tracks.ts, typed on
StackNonStyleProps): a CSS track list along the stack's own axis, so the
children carry no flex props. Wrap (pkgs/ui/stacks) is the one place
content-sized wrapping lives. Both opt in; a stack without tracks renders
byte for byte as before. The owner's plan: these primitives (8.4), then a
codemod per repo with screenshot diffs, then hanzo-design-lint's flex-prop
floor enforced at zero, then the stack default flips to grid in gui 9 only.
- Web:
display: grid; YStackgrid-auto-flow: row+grid-template-rowsgrid-auto-columns: minmax(0, 1fr), XStackcolumn+-columns+grid-auto-rows: minmax(0, 1fr). Merged at importance 0 after the prop loop, so a prop the author names (display="none") wins. The axis isflexDirection(row/row-reverse across, else down); no reverse flow.
- Alignment keeps its flex meaning. On a vertical stack grid's axes swap,
so propMapper renames every key it emits (
styleState.renames): on the stackalignItems→justifyItems,justifyContent→alignContent,alignContent→justifyContent; on a direct childalignSelf→justifySelf. Renaming the emitted key is what makes it hold in media, pseudo and variant styles. A child learns its parent's axis fromComponentContext.tracks, which createComponent provides to a stack's children and every child clears for its own (merged into the focusWithin / inText providers, so the innermost provider still carries both). An HOC and asChild pass it through. - Native stays flex; createComponent hands each child the track at its
position as flex longhands (
place):Nfr→grow N shrink 1 basis 0, a length→grow 0 shrink 0 basis N, anything else→basis auto. Positions run through fragments and skip absolute/fixed children. - The compiler leaves a tracks stack and its direct children to the
runtime (
deoptProps+hasTracksParentin createExtractor). - Tests:
pkgs/ui/components-test/Tracks.{web,native}.test.tsx, the compiler case instatic-tests/tests/babel.web.test.tsx, and real-browser geometry inapps/tests/integration/tests/tracks.integration.test.js(dev and the compiled production build). A spawned server's stdout must be drained there, or the vite plugin's warnings fill the pipe and the server never listens.