our verdaccio proxies and caches all of npm plus @hanzo, @luxfi and @zoo.
no lockfile changes: pnpm-lock v9 records integrity not a registry host, and
npm honors .npmrc at fetch time regardless of what package-lock.json resolved.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
hanzo-app-base (ns hanzo, do-sfo3-hanzo-k8s): SQLite-only Base instance,
IAM-native against hanzo.id (external-auth-only, JWKS), internal ClusterIP.
Defines the projects collection (user_id, space_id, prompts json,
created/updated) with UNIQUE(user_id, space_id) + (user_id, created) via a
JS migration applied at boot. NetworkPolicy pins the only caller to
hanzo-app pods. No image rebuild, no plaintext secrets.
Note: base:0.39.11 reads IAM_ENDPOINT (not IAM_URL) to activate IAM.
Track the Base points engine on the wire:
- client: add WaitlistClient.neighborhood() (the scalable rank +/- window "around
me" view backed by keyset seeks) + NeighborhoodInput/NeighborRow/
NeighborhoodResponse; extend ActivityType with 'social' | 'hanzod' and
PointBreakdown with optional social/hanzod subtotals.
- mock-api (executable spec): add GET /neighborhood and the server-to-server
POST /award (Bearer WAITLIST_AWARD_SECRET, source->points, deduped per
(source,dedupKey)); add SOCIAL/HANZOD point values, social/hanzod breakdown
categories, and social/hanzod activity types. The mock stays byte-for-byte
with plugins/waitlist in hanzoai/base.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Containerize apps/web (@hanzo/waitlist-demo, Next.js 16). Enable Next
standalone output (output: standalone + outputFileTracingRoot for the
pnpm monorepo so the @hanzo/waitlist widget is bundled). Add multi-stage
Dockerfile (pnpm+turbo build -> minimal node:22-slim standalone server,
no nginx/caddy, port 3000) and .github/workflows/build.yml that
builds+pushes ghcr.io/hanzoai/waitlist on v* tags via the canonical
hanzoai/.github docker-build reusable workflow on the self-hosted arcd
fleet. Semver-only tags.
Co-authored-by: hanzo-dev <z@hanzo.ai>
Three concerns were braided inside <Waitlist>:
1. Joining (POST + form state machine)
2. The gamified post-join view (rank + points + share + invite)
3. Persistence (a localStorage key shape duplicated in the host page)
Pulled them apart. Each is independently useful; <Waitlist> is now a
thin composer of the three.
New surface — every piece exported
• <WaitlistJoin waitlist baseUrl onSuccess onError ...> — pure form.
No localStorage, no view machine. Drops a POST and notifies. Useful
when the consumer wants a custom success page or a multi-step flow.
• <WaitlistStatus entry waitlist baseUrl onEntryChange ...> — pure
presentational rank + points + share + invite. Pass it any entry;
it renders. No fetch on mount, no storage write. Useful on /me-style
pages where the entry is server-rendered.
• storage.ts — single owner of `hanzo-waitlist:<slug>` key shape:
usePersistedEntry(slug) — reactive hook (same-tab + cross-tab)
readPersistedEntry(slug) — sync getter (SSR-safe)
writePersistedEntry(slug,e) — single writer; emits a custom event
so same-tab subscribers update
clearPersistedEntry(slug) — sign-out helper
captureReferrerFromUrl() — pulls inbound ?ref= for later submit
readPendingReferrer() — what Join reads at submit time
entryKey(slug) — for consumers who need the raw key
<Waitlist> behavior is unchanged
It now: uses usePersistedEntry to know whether to render <WaitlistJoin>
or <WaitlistStatus>, calls writePersistedEntry on a successful join, and
re-fetches /status on mount when the cache says we're already in.
Demo page
apps/web/app/page.tsx dropped the inline localStorage parse and its ad-hoc
CachedJoin type. It calls usePersistedEntry(slug) — same hook the widget
uses — and the topbar pill flips reactively when the widget writes. No
prop drilling between widget and page. No duplicated key shape.
Rich Hickey nodding somewhere.
Adds real social proof and a glossy depth pass on top of the existing
brand-neutral monochrome. Every new piece is configurable end-to-end.
Activity feed
• Mock-api: GET /v1/waitlist/activity?waitlist=&limit=&types= returning
most-recent events with a server-now timestamp so clients render
consistent relative times across clock skew. Ring-buffer last 200
events (env override: ACTIVITY_MAX_HISTORY). Cap on limit: 100
(env: ACTIVITY_LIMIT_CAP). Types filter (join,share,invite,referral)
is server-validated, unknown types silently dropped.
• join handler pushes a `join` event + a `referral` event when the
sender's refCode resolves. track-share pushes a `share` event the
same day-it-was-credited (skipped on duplicate same-day to match
the daily-cap economy). invite pushes one event per accepted email.
• Seeder synthesizes 30 backdated events on first boot so the ticker
is alive on first paint, not waiting on real traffic.
• Widget: <WaitlistActivity waitlist baseUrl limit types pollInterval
formatEvent formatRelative showHeading heading emptyLabel theme
className style /> — every visible knob is a prop. Polling interval
is configurable (0 disables, default 12s). Default formatter handles
copy/webshare/email/sms with natural copy ("copied their link",
"shared via system", "shared by email", "texted their link"); other
platforms get a clean platformLabel mapping (twitter→X, etc.).
Animated count-up
• useCountUp(target, { duration, ease, retrigger }) hook — 1200ms
cubic-out by default, honors prefers-reduced-motion, 0 disables.
• <WaitlistLeaderboard animateTotal={true | false | { duration }}/>
runs the count-up on the "N on the list" subtitle when the meta
first lands. Truly off-switchable.
Glossy aesthetic (all monochrome — no new color tokens)
• Cards: multi-layer shadow + a 1px inner highlight on the top edge
using inset color-mix(--hw-bg). Reads as real material without
breaking neutrality.
• Leaderboard rows: 6px radius, translateX(2px) on hover (skipped
under prefers-reduced-motion).
• Activity pulse: 8px accent dot with a soft expanding ring keyframe
(1.8s loop). Reduced-motion turns it off.
• Demo hero: subtle radial gradient halo via .section--glow::before
using color-mix(--fg 6%) to stay neutral. Disabled when the user
prefers reduced transparency.
• Topbar Join pill: inset top highlight + accentuated drop shadow
on hover for that glossy CTA look.
• Two-column board grid on desktop (leaderboard + Live activity),
stacks on <=880px.
• OpenGraph + Twitter card metadata on the demo for proper social
previews when someone tweets the page URL.
• Sticky translucent topbar with backdrop blur. Hanzo mark + "Hanzo"
wordmark on the left, big black "Join the waitlist" pill on the right.
Click smooth-scrolls to the join section and auto-focuses the email
input. `scroll-padding-top: 76px` keeps the headline below the topbar
when an anchor fires.
• After join, the topbar pill morphs to "#1,206 · Share". One tap
fires the Web Share API (navigator.share) with a prefilled message;
falls back to clipboard write when the API is missing. Page rehydrates
the rank from localStorage on mount and refreshes it with a status
fetch so it stays live across reloads.
• Leaderboard component: new `mode: 'load-more' | 'paginate'` prop,
defaulting to load-more. Removes the page counter and arrows entirely
in load-more mode — single "Show more" button at the bottom appends
the next chunk (dedup-by-rank) and disappears when the list is
exhausted, replaced by a quiet "That's everyone" label.
`paginate` mode still available for callers who want classic next/prev.
• Rank number celebration: cubic-bezier scale-pop (0.6 → 1.08 → 1)
when the success state mounts. Honors prefers-reduced-motion.
• Removed the now-redundant FAB — the sticky topbar covers the
"always-reachable Join CTA" case on every viewport.
Dropped the meta hero ("@hanzo/waitlist · A waitlist that wears your
colors…"), the stats row, and the brand-swap eyebrow. This now reads as
a product, not a sales pitch for the package.
• Hanzo mark (inlined from ~/work/hanzo/logo getMonoSVG, currentColor
fill so it picks up the theme) replaces the eyebrow at top-left.
• Full-width Leaderboard is the first content the visitor sees.
Centered "Waitlist" title + lede above it. The leaderboard's own
--hw-* tokens make it pick up the theme without any wrapper styles.
• Join section in a tinted band below: copy on the left ("Join the
waitlist" + 3 bullet point-values), widget card on the right. Stacks
on <=880px with the intro centered and bullets left-aligned inside.
• Widget defaults updated to "Reserve your spot / Free and instant.
No spam." — sounds like a product, not a feature flag.
• Footer is one line with the mark + "Hanzo · built on Base" + MIT.
• Removed the "What ships" feature grid and the "Three lines" code
block — both are dev-marketing, both belong in the README, not
on the live waitlist page.
• Preset swatches removed from chrome; dev still can cycle them by
pressing `b` (also skipped when typing in an input/textarea). The
brand-neutral guarantee still demonstrable, just not in the user's
face.
• Page <title> updated: "Hanzo · Waitlist".
Restructured the demo page around a real social-proof hero and pushed the
leaderboard above the fold next to the join widget. Verified at 375 /
768 / 1280 / 1920 px.
Demo page
• Hero rebuilt: tight eyebrow, h1 with em emphasis ("wears YOUR colors"),
lede, and a stats row (live total / share platforms / lines to ship).
Live total skeleton-shimmers while /v1/waitlist/list fetches.
• Live demo section: leaderboard and widget side-by-side on >=881px
(1.55fr : 1fr), stacked on smaller with widget pinned to top + centered
via flex. Sticky widget on desktop so it follows the leaderboard scroll.
• Override CSS hidden behind a <details> reveal — code reveals only when
a developer actually wants to see it.
• Feature grid bumped to 8 cards covering the gamified surface (points,
share targets, invite, leaderboard, anti-abuse). 240px minmax keeps
rows neat across breakpoints.
• Hover, focus-visible, and ::selection states. New skeleton-shimmer
keyframe for the loading total.
• Light + dark theme via prefers-color-scheme; color tokens mirror the
widget's so they breathe together.
Widget polish
• Surface tokens kept Hanzo Radix gray; --hw-surface-2 used for the
score block to soften it from the card.
• Score block: 2-column grid (number | breakdown) instead of stacked,
collapses to 1 column under 380px. Breakdown rows truncate cleanly.
• Rank font now clamps 48–64px so it never overflows narrow widgets.
• Card padding clamps to 18px under 480px.
• Share grid: 92px minmax desktop, 108px tablet, 2-col under 360px.
• Leaderboard rows: 64px rank column on desktop, 48px under 420px;
hover state added. Pager spans the head row on narrow.
• Success state fades in (220ms ease-out); honored prefers-reduced-motion.
Turn the waitlist into a real game. Per-action point values are
configurable per consumer (env on the server, prop on the widget). Same
brand-neutral monochrome shell, now with score, breakdown, share grid,
and an invite-friends flow.
Hanzo monochrome defaults
Aligned widget defaults to ~/work/hanzo/gui's Radix-gray scale:
light --hw-bg #fcfcfc --hw-fg #202020 --hw-muted #646464 --hw-border #e8e8e8
dark --hw-bg #111111 --hw-fg #eeeeee --hw-muted #b4b4b4 --hw-border #2a2a2a
Plus --hw-surface-2 (gray2) for soft panel backgrounds. Same fallback-at-usage
pattern for --hw-accent / --hw-accent-fg / --hw-radius so consumers re-skin
on any ancestor without specificity battles.
Points engine (apps/mock-api)
• each entry has pointBreakdown { referrals, shares, invitesSent, invitesConverted }
• env-configurable values: POINTS_REFERRAL (10), POINTS_SHARE (2),
POINTS_INVITE_SENT (1), POINTS_INVITE_CONVERTED (5)
• POST /v1/waitlist/track-share { waitlist, refCode, platform } — awards
POINTS_SHARE once per refCode per platform per day (anti-spam)
• POST /v1/waitlist/invite { waitlist, refCode, emails, message? } — awards
POINTS_INVITE_SENT per valid email; logs the would-send for prod parity;
rate-limited per IP (5 batches/hr); max 50 emails per batch
• invited friend later joining via the referrer's refCode auto-awards the
invitesConverted bonus on top of the regular referral credit
• leaderboard sorts by total points DESC, createdAt ASC
• seed function synthesizes plausible breakdown so the seeded leaderboard
has real gamification depth, not just referral counts
Widget — new components
• <WaitlistShare> — Web Share API as primary slot (hidden when unsupported),
+ email/X/LinkedIn/Facebook/Reddit/Telegram/WhatsApp/SMS/copy. Each chip
shows a +N points badge. Click fires-and-forgets POST /track-share, then
opens the platform URL (new tab) or runs the local action (clipboard /
native share sheet). shareTargets prop accepts platform IDs OR custom
ShareTarget objects with their own href/onClick + points.
• <WaitlistInviteFriends> — collapsible panel: paste/type emails (any
delimiter: comma, semicolon, newline, space), optional personal note,
submit -> /invite. Shows "Sent N / skipped M / invalid K / +X pts"
result inline. Self-emails and duplicates filtered client- and
server-side.
Widget — gamified success state
Score block under the rank: huge POINTS NUMBER + "PTS" suffix, then a
2x2 breakdown grid showing each source with its multiplier
("0 referrals ×10", "8 shares ×2", ...). Bumps optimistically on
share/invite without a full status refetch — server is still the source
of truth on next page load.
Verified live (mock-api at :8090, demo at :3000, Hanzo monochrome)
Joined fresh user. Score climbed 0 -> 8 (clicked X/LinkedIn/Reddit/Copy,
4 platforms x +2) -> 13 (sent 5 invites x +1). Daily-cap proven: a
second tap on X awarded 0 with "alreadyClaimed: true". Mock-api logs
each would-send invite with the sender + recipient pair.
Adds a public leaderboard view alongside the per-user widget. Same
monochrome surface, same CSS variables, same brand neutrality — any
consumer who already styled <Waitlist> gets the matching leaderboard
for free.
What landed
- packages/widget: new <WaitlistLeaderboard> component. Paginated,
rank-sorted, mobile-friendly grid (rank | masked email | refs).
highlightEmail prop visually marks the current viewer's row.
pageSize prop (default 25, max 500).
- WaitlistClient.list({ waitlist, page, pageSize }) wraps the new
/v1/waitlist/list endpoint. parseGeneric<T> extracted so list and
status share error handling.
- Robinhood-style framing on the per-user success state: rank number
stays huge but now has an explicit "of N" subline immediately below
(#1,201 / of 1,201). Reads instantly as social proof — "this is a
real list, with real scale".
- apps/mock-api: new GET /v1/waitlist/list endpoint (paginated, masked
emails for non-admin viewers, full emails + refCodes when Bearer
auth attached). SEED=N SEED_SLUG=slug env auto-populates N entries
with a Zipf-ish referral distribution (top has ~40 refs, long tail
of zeros) using a deterministic PRNG so reloads stay identical.
- apps/web: new "Leaderboard" section under the live demo. Pulls
joined email out of localStorage to wire highlightEmail. Brand
preset (Neutral / Preset A-D) cascades into the leaderboard via
the same wrapper class — one re-skin covers both.
Verified live (mock-api at :8090, demo at :3000)
- 1,201 entries (1,200 seeded + 1 demo join)
- Widget: # 1,201 / of 1,201, all-green Preset C
- Leaderboard card: 1,201 on the list, 49 pages of 25
- Pagination navigates cleanly (page 1 -> 3 -> back)
- Top of list: #1=40 refs, #2=15, #3=8 (Zipf intact)
- Emails masked: g*********0@example.io
Adds apps/mock-api/ — a ~200-line Node http server that implements the
exact wire format of plugins/waitlist (Base Go plugin). Used by the
demo so the full Join → Success-with-rank-and-share flow works
standalone with no Base instance. Doubles as an executable spec for
anyone reimplementing the contract in another language.
Storage is in-memory; restart drops state. Implements:
- POST /v1/waitlist/join idempotent on (waitlist, email), atomic
referral credit, collision-safe refCode
- GET /v1/waitlist/status rank + share URL
- GET /v1/waitlist/export CSV, Bearer-auth via WAITLIST_ADMIN_SECRET
Same anti-abuse defenses as the Go plugin: crypto/rand refCodes from a
36-char alphabet that excludes confusing glyphs, disposable email
blocklist, per-IP sliding-window rate limit (5/IP/hour default), email
normalization. Turnstile is a host-side concern in the mock.
Wired into the demo via NEXT_PUBLIC_BASE_URL=http://localhost:8090.
README updated to document the two-terminal dev flow.
Drops the Next.js + Upstash Redis stack. The viral waitlist now ships as
a brand-neutral React widget against a Hanzo Base Go plugin.
What changed
- packages/widget/: new @hanzo/waitlist — React component, framework-free
fetch client, drop-in <hanzo-waitlist> custom element. Zero brand
colors, zero logos, zero brand names. Consumer brings logo via `logo`
prop or `<slot name="logo">`. Theming via --hw-* CSS variables with
fallback chain at usage so ancestor selectors win cleanly.
- apps/web/: Next.js demo rebuilt monochrome. Five generic presets
(Neutral, Preset A/B/C/D) prove brand swap works without per-brand
builds. Demo's placeholder logo lives in the demo, not the package.
- apps/api/: removed. Backend logic now lives in
~/work/hanzo/base/plugins/waitlist (committed separately).
- packages/sdk/: removed (Preact + Vite). Replaced by packages/widget.
- docs/: collapsed to architecture / api / widget. Old planning docs
moved to docs/archive/.
- Latest deps: Next 16, React 19.2, Vite 8, TS 5.9, vite-plugin-dts 5,
@vitejs/plugin-react 6, eslint-config-next 16.
Build output
- ESM 6.76kB, CJS 5.22kB, IIFE 196.99kB / 62.65kB gzip
- Widget styles: 3.89kB / 1.18kB gzip
- Demo first-load: 106kB