The hint is the ink at 60% on dark and 65% on light, 4.5:1 or better on the
booking form's fields in both themes and still plainly dimmer than a typed
value.
Co-authored-by: Hanzo Dev <[email protected]>
@hanzo/booker 8.3.8 makes days, month arrows and times real buttons with a
visible ring, one tab stop each for the month and the times with arrow keys
between them, real <label>s, an email field of type email, and 16px field
text so iOS does not zoom. The booking page focuses its title when a booking
lands there, so the keyboard is not left on nothing.
The backend's public profile names a host by its booking handle ("hanzo"), so
team.ts names the team calendar this app opens on, and the home page, the
/{owner}/{slug} page and the embed show it as Hanzo AI.
Co-authored-by: Hanzo Dev <[email protected]>
The app mounts <Hanzo> from @hanzo/ui with its stylesheet instead of a stock
gui config, so the Booker reads hanzo.ai's own tokens. @hanzo/ui 8.27.89
draws a placeholder at the ink's 45% and the value at the full ink, and
@hanzo/booker 8.3.7 carries no sample-data placeholders ("Your name",
"[email protected]"), a white pill Confirm (black on light) and a quiet Back.
The page follows the device's light or dark, as hanzo.ai and its checkout do,
painted in <head> before first paint. Grounds are the checkout's: #000 or #fff,
fields on #171717 / #ededed under the #2e2e2e / #d5d5d5 hairline. One layout,
the mark then one card, on every page: the landing's frame around the Booker's
own card is gone, the booking page is a single card with a white-pill Keep it,
and the popup embed draws the Booker bare inside its panel, which follows the
device too. An embedded document decides it is framed in <head>, so it is
see-through before React mounts.
Co-authored-by: Hanzo Dev <[email protected]>
The lane built only sha-<7> tags, which universe's pin.sh refuses — so
every cal image was unreachable by cd and the pin sat frozen at v0.2.0.
Adopt the fleet shape: imgver derives max(declared, published)+patch,
the build publishes v<semver>, and Ship-it reads UNIVERSE_PIN_TOKEN from
KMS to pin cal-web. Matches chat/billing; keeps cal's v-convention; pins
cal-web, never the frozen cal booking backend.
HANZO_STATIC_CSP was read by nothing — hanzoai/spa reads SPA_CSP — so this image
has been serving no policy of its own. The rename does not change what ships
either: that server sets Content-Security-Policy only on the branch where
framing is refused, so ALLOW_FRAMING=true drops the whole header, on every path.
Measured against sha-b9d3a13: no CSP and no X-Frame-Options on / or /booking/x;
with framing unset, the value here is served in full. Making it ship needs the
shared image to set the header unconditionally and let framing pick
frame-ancestors instead of picking whether there is a policy — hanzoai/spa, not
here. The value is written to be correct the day it does.
What framing does expose is the cancel. The old comment said nothing here holds
a session or a credential; the uid IS the credential, the allowance is
per-server so it covers /booking/<uid>, and one click destroyed a meeting.
Cancelling now takes two, and Keep it is what sits where the first click was, so
the same click twice keeps the booking. Measured: the destructive target no
longer overlaps the first button's box.
Every message the loader and the framed document exchange is now bound to the
frame entitled to send it, and the popup declares who is serving it:
- the popup draws a bar naming its origin. data-* is what survives an HTML
sanitizer, so a page that accepts user HTML can be made to conjure a
full-viewport overlay with no script at all; the bar is drawn in the loader's
own DOM under all:initial !important, which a scriptless injection cannot
remove, so the overlay cannot pass for the host page. The attribute keeps
working — routing the popup through HanzoCal.open instead would have broken
every host page already using it.
- a reported height is clamped to 5000px. It was applied verbatim, so one
message could paper the host page over.
- close is bound to the popup's own frame, the way height was already bound to
the frame that reported it. Two bookers on one page and one Escape closed the
wrong dialog.
- the framed document posts to the origin the loader names in ?parent=, never
"*". Measured from a third-party page: a frame addressed elsewhere delivers
nothing, one addressed here delivers.
- a key segment of nothing but dots is refused. `.` and `..` are steps through a
path rather than names, and both segments are spliced into the public-config
URL the Booker fetches. Dots inside a name stay legal.
<AnalyticsProvider> took autoPageview's default of true, and @hanzo/event
stamps url and path on every event it builds, so loading /booking/<uid> POSTed
that uid to the shared warehouse. The uid is the booking's capability: it reads
the attendee and cancels the meeting. Measured on the built bundle — before,
one $pageview carrying path:"/booking/bk_…"; after, no request at all, while /
still reports pricing_viewed.
The rest of what leaves the browser, in the same pass:
- keyRejected is gone. It put the verbatim ?pk=, of no bounded length, into the
properties of a row on the shared warehouse and flushed on the spot — one
POST per load of a page anyone can load in a loop, for a signal whose error
plane cal has never configured. A key that resolves to nothing is a mistyped
host page, not a fault; the visitor is told on the page.
- framed, the client is off. The id it stamps is the fleet's hz_anon_id, a
cookie on hanzo.ai that every Hanzo surface shares, and minting it inside
someone else's page is cross-site tracking. Measured: no cookie and no
localStorage entry from a third-party frame, both still written on cal's own
page. The inline and popup surfaces stop reporting until @hanzo/event can
mint an embed-scoped id.
- Do Not Track in the three spellings hz.js honors beyond navigator's "1".
- the comment claiming an absent ingest key leaves the plane inert was wrong:
the client still POSTs, it just omits the Authorization header.
Both files still described the @calcom/atoms era: a packages/calendar wrapper
that is gone, a .github/workflows CI that was retired, and GitHub as canonical
when the remote is the forge. Correct those, and document the embed surface,
the publishable key and the funnel — including the two traps that cost time
here: @hanzo/booker imports its peer as `hanzogui` (Vite and tsconfig must both
say so), and hanzoai/spa refuses framing unless ALLOW_FRAMING is set.
A host page gets a booker in two lines: a [data-hanzo-cal] element carrying a
pk_ key and one ~2 kB classic script. Add [data-hanzo-cal-popup] and the same
key opens it over the page instead.
The key is a public selector, not a credential: pk_ + base64url(host/eventSlug),
the same pair the /{host}/{eventSlug} URL already names, so it resolves with no
lookup and no registry and the public config still comes from the event-type
endpoint <Booker> was always calling. Base reserves the hyphenated pk- for
read-only API credentials; this is a different kind of value and is never sent
as one.
Each booker is a frame at the new /embed route, which is what keeps a host's CSS
out — reusing the same <Booker> and the same /v1/calendar calls as the full-page
flow, which is untouched. What a frame cannot do is size itself, so /embed posts
its height and the loader applies it; the same channel carries close, because
Escape pressed inside a frame never reaches the page around it.
Every surface that shows a booker now reports reaching a visitor and creating a
booking, distinguished by one `surface` property: page, hero, inline, popup.
@hanzo/event is the fleet's client tracker, and its taxonomy is closed on
purpose — a dimension is a property, never a new event name — so the funnel
rides the conversion names qualified by kind:'booking', which also keeps free
intro calls out of the Sale goal (order_completed{kind:'plan'}). A key that
names no calendar is not a funnel step but a host misconfiguration, so it goes
to the error plane where someone is watching.
Only the two public identifiers ride along. Never the booking uid: that is the
capability that reveals the meeting location and cancels the booking. DNT and
GPC are honoured because the SDK reads neither, and a booker sitting in someone
else's page should be the polite one. Interior steps stay unreported —
<Booker>'s only lifecycle prop is onBooked, and slot_selected and
details_entered live in its private state machine; reaching them means one prop
upstream in hanzoai/gui, not a fork of the UI here.
hanzoai/spa refuses framing by default, so the image now sets ALLOW_FRAMING.
tsconfig repeats the Vite alias for hanzogui, without which the typecheck this
adds cannot resolve @hanzo/booker's peer.