moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

11
apps/www/.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Waggle Landing Page Environment
# Public landing page API (legacy Vite var — read by src/components/Pricing.tsx
# until §2 ports it to NEXT_PUBLIC_API_URL).
VITE_API_URL=https://cloud.waggle-os.ai
# Stripe checkout — placeholder values trip a "configuration required" guard
# in /api/stripe/checkout instead of attempting a real Stripe call. Real keys
# are a Marko-side pre-launch action; do NOT commit live secrets here.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_REPLACE_ME
STRIPE_SECRET_KEY=sk_test_REPLACE_ME

View File

@@ -0,0 +1,51 @@
# =============================================================================
# Waggle www — env template
# =============================================================================
# Copy to `.env.local` and fill in real values. `.env.local` is gitignored.
# All NEXT_PUBLIC_* vars are exposed to the client bundle; never put secrets
# behind that prefix.
# =============================================================================
# -----------------------------------------------------------------------------
# Clerk auth
# -----------------------------------------------------------------------------
# Publishable key from Clerk dashboard → API Keys. Starts with `pk_test_` (dev)
# or `pk_live_` (prod).
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxx
# Secret key from Clerk dashboard → API Keys. Starts with `sk_test_` (dev)
# or `sk_live_` (prod). Never expose to client.
CLERK_SECRET_KEY=sk_test_xxxxx
# Hosted-flow redirect targets. `/` falls back to landing for sign-in/up
# completion; route-level redirects can override per request.
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/
# Clerk webhook signing secret. Get from Clerk dashboard → Webhooks → endpoint.
# Starts with `whsec_`. Used by svix to verify `user.created` etc.
CLERK_WEBHOOK_SECRET=whsec_xxxxx
# -----------------------------------------------------------------------------
# Stripe (Connected linkage — Stripe Customer ID stored in Clerk publicMetadata)
# -----------------------------------------------------------------------------
# Secret key from Stripe dashboard → Developers → API keys.
STRIPE_SECRET_KEY=sk_test_xxxxx
# Webhook signing secret from Stripe dashboard → Webhooks → endpoint.
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
# Stripe price IDs (tier × billing cycle). Provision via Stripe CLI:
# stripe prices create --product=<prod_id> --unit-amount=<cents> \
# --currency=usd --recurring.interval=month|year \
# --lookup-key=<tier>_<billing> -d "metadata[tier]=<tier>" \
# -d "metadata[billing]=<billing>"
# Code resolves IDs by lookup_key at runtime; env vars are explicit pinning.
# LEGACY: Pro is no longer a sold tier (new checkout is Team-only). These
# vars are retained only so legacy Pro subscription webhooks keep resolving.
STRIPE_PRICE_PRO_MONTHLY=price_xxxxx
STRIPE_PRICE_PRO_ANNUAL=price_xxxxx
STRIPE_PRICE_TEAMS_MONTHLY=price_xxxxx
STRIPE_PRICE_TEAMS_ANNUAL=price_xxxxx

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 KiB

69
apps/www/LIGHTHOUSE.md Normal file
View File

@@ -0,0 +1,69 @@
# Lighthouse Audit — Sesija D §3.3 Final Pass
**Date:** 2026-05-03
**Build:** Next.js 15.5.15 production (`next build` + `next start -p 8005`)
**Audit tool:** `npx lighthouse@13.2.0 --chrome-flags="--headless=new --no-sandbox"`
**Page:** `http://localhost:8005/` (homepage, Variant A default)
## Scores (acceptance threshold: ≥85 / ≥95 / ≥95)
| Category | Score | Threshold | Status |
|----------------|--------|-----------|--------|
| Performance | **96** | ≥85 | ✅ |
| Accessibility | **96** | ≥95 | ✅ |
| SEO | **100**| ≥95 | ✅ |
## Core Web Vitals (final)
| Metric | Value | Score |
|---------------------------------|-------|-------|
| First Contentful Paint (FCP) | 1.2 s | 99 |
| Largest Contentful Paint (LCP) | 2.7 s | 86 |
| Total Blocking Time (TBT) | 20 ms | 100 |
| Cumulative Layout Shift (CLS) | 0.054 | 98 |
| Speed Index | 1.2 s | 100 |
## Two fixes that mattered
**1. Layout head fix — SEO 91 → 100.**
Next.js 15 + React 19 streaming SSR pushed `<title>` and `<meta name="description"` into `<body>` (byte 69244+) for client-side hoist via React 19 metadata API. Lighthouse SEO audits the initial HTML head pre-hoist and scored `meta-description` as 0. Fix: render explicit `<title>` + `<meta>` JSX inside `<head>` element in `app/layout.tsx`, using module-level constants (not JSX literals — strict criterion #11 satisfied). Strings duplicated between `metadata` const + JSX head; the static metadata API still feeds crawlers that respect React's hoist while the JSX head guarantees first-byte placement.
**2. Lazy-loaded persona + step images — Performance 74 → 96 (+22).**
Next.js auto-preloads the first ~5 above-fold images detected as LCP candidates. With 13 persona PNGs in `BrandPersonasCard` + 3 bee PNGs in `HowItWorks` rendered as `<img loading="eager">`, Next.js was preloading 5 below-fold persona images and blocking the H1 LCP measurement (reported 66.5 s — animation/preload measurement artifact). Adding `loading="lazy"` + `decoding="async"` to all below-fold `<img>` tags eliminated the preloads; LCP dropped to 2.7 s.
## Sub-100 audits (passing thresholds, but room for v1.5)
**Accessibility (96)**
- 4 instances of color contrast under 4.5:1: `--hive-400` (#5a6380) used at small font sizes (9-11px) on dark backgrounds:
- Navbar `v1.0` version pill (contrast 2.89)
- HeroVisual window strip "local · signed · 42ms" (3.09)
- HeroVisual stat strip labels "EDGES / PROVIDERS / P99 RECALL / CLOUD CALLS" (3.28)
- v1.5 fix: bump to `--hive-300` (#7d869e) at small sizes, OR raise to 12px+ where contrast permits.
**Performance (96)**
- "Reduce unused JavaScript" at score 50 — next-intl + Inter font ship more bytes than strictly needed for a single-locale, three-weight typography use. Tree-shaking next-intl messages that aren't used at runtime is a v1.5 task.
- "Improve image delivery" at 50 — bee PNGs are 2x retina pre-rendered. Switching to `<Image>` from `next/image` would auto-convert to AVIF/WebP and serve responsive srcsets. Larger refactor (13 personas) deferred.
- "Page prevented back/forward cache restoration" at 0 — the `Cache-Control: no-store` from dynamic `/` route (because of `searchParams`) prevents bfcache. Could be mitigated by switching variant resolution to client-side only (with SSR fallback to A) so `/` becomes static. Deferred to v1.5.
## Methodology + reproducibility
```bash
cd apps/www
npx next build
npx next start -p 8005 &
npx lighthouse http://localhost:8005/ \
--output=json \
--output-path=./lighthouse-report.json \
--only-categories=performance,accessibility,seo \
--chrome-flags="--headless=new --no-sandbox"
```
`lighthouse-report.json` is gitignored (regenerable artifact, ~600 KB). Run the command above to reproduce.
**Localhost vs production caveat:** Lighthouse run against localhost has known measurement discrepancies (zero network latency confuses some metrics; the LCP=66.5s pre-fix was a preload-timing artifact). Production CDN / Vercel deployment typically scores **+5 to +10** points across Performance vs the same build on localhost. The 96 / 96 / 100 here is therefore a conservative floor — production should match or exceed.
## Acceptance — amendment §3 #16
> "Lighthouse audit: Performance ≥85, Accessibility ≥95, SEO ≥95"
✅ All three thresholds met on local prod build. Re-measure on Vercel preview before launch as final confirmation.

View File

@@ -0,0 +1,119 @@
# Sesija D Manifest — apps/www Next.js Port v3.2
**Date:** 2026-05-03
**Branch:** main (+11 commits ahead of origin/main at start of Sesija D, +20 at end)
**Mode:** SUPPORT (work main directly, commit per logical milestone)
**Brief:** `PM-Waggle-OS/briefs/2026-04-25-cc1-apps-www-nextjs-port-brief.md` + `2026-05-02-cc-sesija-D-apps-www-port-v3.2-amendment.md`
**Cost cap:** $10 hard / $8 halt
**Actual cost:** $0 LLM spend (pure file/build/lighthouse ops — no eval, no LLM-heavy operations)
---
## Commit ledger (12 commits, 11 CC + 1 Marko mid-session)
| # | SHA | Phase | Description |
|---|---|---|---|
| 1 | `9a6729b` | §1 | Scaffold Next.js 15 App Router migration (Vite 6 → Next 15.5.15) |
| 2 | `e20477f` | §2.1 | Drop 4 components (Features/CrownJewels/Enterprise/BetaSignup) + relocate personas/BrandPersonasCard into app/ |
| 3 | `b8a0020` | §2.2 | NEW lib + data + 6 server components + HeroVisual + DownloadCTA |
| 4 | `b5c3916` | §2.3 | Port Navbar + Pricing (client) into app/_components/ |
| 5 | `c4b0c04` | §2.4 | Wire app/page.tsx with 8-section landing + cleanup legacy src/ |
| 6 | `5464891` | §3.1 | Internal /api/stripe/checkout route + placeholder env guard |
| 7 | `9d7f5c9` | §3.2 | next-intl + full i18n extraction (~190 keys, strict #11) |
| 8 | `b716b04` | §3.3 | Lighthouse audit pass — Performance 96 / Accessibility 96 / SEO 100 |
| — | `7d1e0fc` | (Marko) | docs: add methodology documentation (211-line docs/methodology.md at repo root) |
| 9 | `8ecddff` | §3.4 | Path D landing decoupling — arxiv → methodology in Trust + Footer |
| 10 | `c353e49` | §4 | Initial verification artifacts: variant smoke + 3 full-page screenshots + manifest |
| 11 | (this) | §4.1 | NEW /docs/methodology Next.js route (react-markdown + remark-gfm) + app/sitemap.ts + 4th screenshot |
---
## Acceptance criteria — amendment §3 (16 items)
| # | Criterion | Status | Evidence |
|---|---|---|---|
| 1 | 8 sections in locked IA order (Hero → Proof → How → Personas → Pricing → Trust → Final CTA → Footer) | ✅ | `app/page.tsx` composes in declared order; verified visually in 3 screenshots |
| 2 | 5 hero variants resolvable via `?p=` + `utm_source` | ✅ | §4.1 smoke: A default ✓ / B `?p=compliance` ✓ / C `?p=founder` ✓ / D `?p=developer` ✓ / E `?utm_source=legal-tech` ✓ |
| 3 | Hero microcopy lock #1 (`17 AI platforms · Local-first · Apache 2.0 · EU AI Act ready`) | ✅ | `messages/en.json` `landing.hero.microcopy` |
| 4 | Hero diagram bottom stats lock #2 (`17 PROVIDERS`, not `4 PROVIDERS`) | ✅ | `messages/en.json` `landing.hero_visual.stats.providers_value: "17"` |
| 5 | Proof Card 1 = GEPA `+12.5pp` (Trio-strict 33.5% dropped) | ✅ | `app/_data/proof-points.ts` first card; rendered in screenshots |
| 6 | Proof Card 2 description lock #4 (`Substrate beats Mem0 paper by 7.1 points on LoCoMo.`) | ✅ | `app/_data/proof-points.ts` LoCoMo card |
| 7 | Step 02 ending lock #5 (`...persists across providers, sessions, and machines, automatically.`) | ✅ | `messages/en.json` `landing.how_it_works.step_02.body` |
| 8 | 13 personas per LOCK 2026-04-22 (Sleeping #13, Sovereign tile NOT added — Edit 5 RESCINDED) | ✅ | `app/_data/personas.ts` unchanged; `BrandPersonasCard` renders 13 + 3 fillers in 4×4 grid |
| 9 | Final CTA subhead lock #6 (`...KVARK for sovereign deployments.`) | ✅ | `messages/en.json` `landing.final_cta.subhead` |
| 10 | KVARK bridge in Final CTA (one sentence + one CTA) | ✅ | `app/_components/FinalCTA.tsx` `kvarkBridgeStyle` block |
| 11 | All copy extracted to `messages/en.json` under `landing.*` namespace; STRICT no string literal in JSX | ✅ | ~190 keys; vitest 10/10 passes with mocked t() |
| 12 | Stripe checkout via internal `/api/stripe/checkout` (NOT external cloud.waggle-os.ai) | ✅ | `app/api/stripe/checkout/route.ts`; Pricing POSTs to relative path; 503 with placeholder env vars per §0.b |
| 13 | OS detection on Hero / Solo tier / Final CTA primaries | ✅ | `app/_components/DownloadCTA.tsx` (3 sections wired with `section` prop) |
| 14 | Hive pulse animation + `prefers-reduced-motion` suppression | ✅ | `app/_components/HeroVisual.tsx` scoped CSS `@media (prefers-reduced-motion: reduce)` |
| 15 | Build clean, lint clean, vitest 100% green | ✅ | `next build` → 6 routes; `tsc --noEmit` silent; `vitest run` 10/10 |
| 16 | Lighthouse: Performance ≥85, Accessibility ≥95, SEO ≥95 | ✅ | **96 / 96 / 100** on local prod; see `LIGHTHOUSE.md` for full report |
**16 / 16 PASS.**
---
## Out of scope (per amendment §5, OUT-of-scope locked)
- ThemeToggle / light mode / `[data-theme="light"]` block (v1.5 deferred)
- MPEG-4 hero loop placeholder (post-launch fast-follow; HeroVisual ships SVG diagram per §2.2)
- A/B testing framework (post-launch)
- Server-side rendering of hero variants — variant resolver runs server-side from `searchParams` (acceptable for v1; Next.js App Router idiom)
- Marketing email integration (BetaSignup component dropped per §2.1; CTA conversion handled by Pricing flow)
- Analytics provider integration (event-taxonomy stub only — `console.info` in dev, no-op in prod)
- Cookie banner cross-domain tracking persistence
---
## Day-2 polish backlog (carryover for Marko ratification)
| Item | Severity | Source | Notes |
|---|---|---|---|
| `--hive-400` (#5a6380) contrast at 9-11px sizes | low | Lighthouse a11y sub-100 | 4 instances: navbar v1.0 pill, HeroVisual window strip, stat strip labels. Bump to `--hive-300` or font-size to 12px+. |
| `next/image` migration for 13 persona PNGs | medium | Lighthouse "Improve image delivery" 50 | AVIF/WebP auto-conversion + responsive srcsets. ~200 line refactor. |
| `/` route bfcache restoration | low | Lighthouse perf sub-100 | Currently 0 due to `Cache-Control: no-store` on dynamic searchParams route. Move variant resolver client-side to keep `/` static. |
| next-intl message tree-shaking | low | Lighthouse "Reduce unused JS" 50 | next-intl ships full message bundle to client. v1.5 audit. |
| ~~`/docs/methodology` route handler~~ | ✅ resolved | §4.1 PM ratify | Shipped in §4.1 — `app/docs/methodology/page.tsx` reads `docs/methodology.md` at build (force-static) via react-markdown + remark-gfm. Live at `waggle-os.ai/docs/methodology` once deployed. |
| Vercel preview Lighthouse re-measure | medium | §3.3 carryover | Local 96/96/100 is conservative floor; production CDN typically +5-10. Re-measure on Vercel preview before launch as final confirmation. |
| Stripe real keys + live price IDs | high (pre-launch) | §0.b ratification | Marko-side action Monday 2026-05-03 with finance team. Replace `sk_test_REPLACE_ME` / `pk_test_REPLACE_ME` + populate `STRIPE_PRICE_{PRO,TEAMS}_{MONTHLY,ANNUAL}` env vars. Route auto-stops returning 503 once real keys are in place. |
| Sign-in flow | low (post-launch) | navbar | Currently `href="#"` placeholder. Auth provider + sign-in page defer to post-launch. |
| `BrandPersonasCard.tsx` font-family inheritance | low | spotted in §3.3 polish | One sub-100 a11y issue is a contrast finding; the broader fix (centralize all typography on `var(--font-inter)` rather than fallback `'Inter', system-ui, sans-serif` strings scattered through components) is a v1.5 simplification. |
---
## Verification artifacts
- **Build output:** `next build` produces **8 routes** (`/` ƒ Dynamic 8.96kB / 127kB First Load · `/_not-found` ○ Static 989B · `/api/stripe/checkout` ƒ Dynamic 129B · `/design/personas` ○ Static 2.98kB · `/docs/methodology` ○ Static 129B / 102kB · `/sitemap.xml` ○ Static).
- **Tests:** `npx vitest run` → 10/10 BrandPersonasCard tests pass.
- **Typecheck:** `npx tsc --noEmit` → clean (silent).
- **Lighthouse:** local prod build at port 8005 — **Perf 96 / A11y 96 / SEO 100**. Full report in `apps/www/LIGHTHOUSE.md`.
- **Variant smoke:** all 5 (A/B/C/D/E) resolve correctly, eyebrow text matched per variant via `?p=` or `utm_source` param.
- **Screenshots:** `apps/www/.screenshots/` contains **4 full-page PNGs** at 1440×900 (variant A default + variant B compliance + variant E legal-tech + `/docs/methodology` page). Mid-page persona tiles render lazy-loaded; this is intentional design — `loading="lazy"` was the perf fix that boosted LCP from 66.5s → 2.7s.
- **Sitemap:** `app/sitemap.ts` auto-generates `/sitemap.xml` with 2 entries (homepage priority 1.0 weekly + `/docs/methodology` priority 0.7 monthly). `/design/personas` intentionally omitted (robots-blocked playground).
---
## Repo state at end of Sesija D
- `apps/www/src/` is **empty** (all 9 legacy components either dropped per §2.1 or ported into `app/_components/` via §2.2 + §2.3).
- `apps/www/vite-env.d.ts` deleted (no more `import.meta.env` consumers).
- `apps/www/app/` houses everything: `_components/` (12 files) + `_data/` (3 files) + `_lib/` (3 files) + `api/stripe/checkout/route.ts` + `design/personas/` (preview playground + 2 PNGs) + `docs/methodology/page.tsx` (NEW §4.1) + `sitemap.ts` (NEW §4.1) + `globals.css` + `layout.tsx` + `page.tsx`.
- `apps/www/messages/en.json` is the canonical i18n source (~190 keys).
- `apps/www/i18n/request.ts` configures next-intl single-locale (`en`).
- `apps/www/LIGHTHOUSE.md` documents the audit + remediation backlog.
- Branch +20 commits ahead of `origin/main` (12 pre-Sesija D + 8 CC commits + 1 Marko commit + this manifest commit).
---
## Standing-down
Sesija D scope per amendment delivered in 9 commits (10 with Marko's methodology.md mid-session). All 16 amendment §3 acceptance criteria meet thresholds. Build green, types green, tests green, Lighthouse green.
PM Pass 8 ready. Marko-side actions before Day 0 launch:
1. Push `main` to `origin/main` (currently +22 ahead)
2. Wire real Stripe keys in production env (Vercel/Cloudflare/wherever production lands)
3. Re-measure Lighthouse on Vercel preview as final confirmation (re-test `/docs/methodology` since it's now a real route, not just a planned link)
4. Resolve sign-in flow CTA (currently `#` placeholder)
5. Confirm `docs/methodology.md` content is locked before deploy (currently labeled "Status: Draft for github commit") — methodology page bakes this content at build time, so updates require a rebuild + redeploy.
Cost cap unspent. Standing down.

View File

@@ -0,0 +1,188 @@
# Sesija E Manifest — Clerk auth + Stripe-Clerk linkage + branding fixes
**Date:** Started 2026-05-03, last entry 2026-05-04
**Branch:** main (SUPPORT mode — work main directly, commit per logical milestone)
**Brief:** CC Sesija E — Clerk auth + Stripe-Clerk linkage + FR Pass8-A logo fix
**Cost cap:** $15 hard / $12 halt
**Status:** §5.0§5.3 COMPLETE · §5.4 / §5.5 / §5.6 PENDING
---
## Commit ledger (4 commits to date — extends through §5.6)
| # | SHA | Phase | Description |
|---|---|---|---|
| 1 | `4365897` | §5.0 | Scaffold Clerk integration (deps + middleware + ClerkProvider in layout) |
| 2 | `d281a86` | §5.1 + §5.2 | Wire Clerk auth UI in navbar + account page (`SignedIn` / `SignedOut`, `/account` `UserProfile`) |
| 3 | `0147d6c` | §5.3 Phase A | Provision Stripe test-mode prices via CLI — Path A idempotent reuse, no new products created |
| 4 | `a087cf6` | §5.3 Phase C | Connect Stripe Customer to Clerk user.publicMetadata via lazy-create pattern; add webhook handler |
| 5 | (this) | §5.3 Phase E | Manifest + smoke-test instructions |
---
## §5.3 Stripe catalog state (test mode, account `acct_1SzHlbC0mmjh4oEM`)
| Lookup key | Price ID | Amount | Product | Metadata |
|---|---|---|---|---|
| `pro_monthly` | `price_1TNZfkC0mmjh4oEMGAZ2PDbc` | $19/mo | `prod_UMIG4B7V0Ke6zQ` (Waggle Pro) | tier=pro, billing=monthly |
| `pro_annual` | `price_1TTN4FC0mmjh4oEMyaEX40Kl` | $190/yr | `prod_UMIG4B7V0Ke6zQ` (Waggle Pro) | tier=pro, billing=annual |
| `teams_monthly` | `price_1TNZfpC0mmjh4oEMH10c02YB` | $49/mo | `prod_UMIGZ99xtazCAs` (Waggle Teams) | tier=teams, billing=monthly, seat_minimum=3 |
| `teams_annual` | `price_1TTN4HC0mmjh4oEMsgKwV1MY` | $490/yr | `prod_UMIGZ99xtazCAs` (Waggle Teams) | tier=teams, billing=annual, seat_minimum=3 |
**Archived in Phase A** (older 2026-04-08 duplicates, no longer active):
- `prod_UG0WP8RCBvaiQ2` (Waggle Teams 2026-04-08, wrong amount)
- `prod_UG0Wk4DLVpsRwf` (Waggle Basic 2026-04-08, no longer in tier roadmap)
Recreating these IDs on a new Stripe account: see `apps/www/.env.local.example` for the CLI provisioning command template.
---
## §5.3 Phase B — Local webhook listener (Marko-side, separate terminal)
For local dev webhook testing, run in a terminal separate from `next dev`:
```powershell
cd D:\Projects\waggle-os
stripe listen --forward-to localhost:3001/api/webhooks/stripe --print-secret
```
Stripe CLI prints the signing secret (`whsec_...`) on first run — paste
it into `apps/www/.env.local` as `STRIPE_WEBHOOK_SECRET=whsec_...`. Leave
the listener running for the duration of the test session; the secret
itself is stable across restarts of the same `stripe listen` invocation.
**Why CC didn't run this:** `stripe listen` is a long-running process. CC
sessions can't host indefinitely-blocking foreground commands without
losing the rest of the conversation, so this is documented for Marko to
run instead. The webhook handler at `apps/www/app/api/webhooks/stripe/route.ts`
is fully implemented and waiting to receive deliveries once the secret
is pasted and the listener is up.
**Production webhook setup is Marko-side ponedeljak 14:00:**
1. Stripe Dashboard → Developers → Webhooks → "Add endpoint".
2. Endpoint URL: `https://waggle-os.ai/api/webhooks/stripe` (or whichever production deploy URL).
3. Subscribe to events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`.
4. Copy the new endpoint's signing secret into the production env as
`STRIPE_WEBHOOK_SECRET=whsec_...` (production secret is **different**
from the local `stripe listen` secret).
5. Live key swap: replace `STRIPE_SECRET_KEY=sk_test_...` with
`STRIPE_SECRET_KEY=sk_live_...` in production env. The route accepts
both `sk_test_*` and `sk_live_*` automatically — no code changes
required.
---
## §5.3 Phase E — End-to-end smoke test
**Pre-conditions** (run once, then leave running):
| | Action |
|---|---|
| 1 | `STRIPE_SECRET_KEY=sk_test_*` (real value, not placeholder) is in `apps/www/.env.local` |
| 2 | `STRIPE_WEBHOOK_SECRET=whsec_*` (from `stripe listen --print-secret`) is in `apps/www/.env.local` |
| 3 | All 4 `STRIPE_PRICE_*` IDs in `apps/www/.env.local` (already done in §5.3 Phase A) |
| 4 | Terminal 1 — Next.js dev server running. Either `cd D:\Projects\waggle-os\apps\www && npx next dev -H 0.0.0.0 -p 3001`, or from repo root `npm run dev -w apps/www -- -H 0.0.0.0 -p 3001` (`-w` is the npm workspace flag; `--` separates npm args from forwarded script args). |
| 5 | Terminal 2 — `stripe listen --forward-to localhost:3001/api/webhooks/stripe` is running |
**Browser walkthrough:**
1. Visit `http://localhost:3001` → click **Sign up** in the navbar → complete Clerk sign-up (any test email, any password ≥ 8 chars).
2. Land on `/pricing` (or scroll to the pricing section on `/`).
3. On the **Pro** tier card, click "Start free trial" (or whatever the CTA is — see `landing.pricing.tiers.pro.cta` in `messages/en.json`).
4. The button currently POSTs to `/api/stripe/checkout` and opens the returned URL in a new tab. (§5.4 will migrate this to a GET-based redirect flow.)
5. On Stripe Checkout, use test card `4242 4242 4242 4242` (any future expiry, any 3-digit CVC, any ZIP).
6. After payment, Stripe redirects to `/account?checkout=success&session_id=cs_...`.
**Verify state:**
| Check | Where | Expected |
|---|---|---|
| Clerk user metadata | Clerk Dashboard → Users → your user → "Public metadata" tab | `stripeCustomerId: cus_...` · `subscriptionTier: pro` · `subscriptionStatus: active` |
| Stripe customer | Stripe Dashboard test mode → Customers → newest customer | `metadata.clerkUserId = <your Clerk user id>` |
| Webhook delivery | Terminal 2 (`stripe listen` log) | `checkout.session.completed [200]` · `customer.subscription.created [200]` · `customer.subscription.updated [200]` |
| Subscription on customer | Stripe Dashboard → Customers → your customer → Subscriptions | One active subscription on the price you selected (Pro monthly = `price_1TNZfkC0mmjh4oEMGAZ2PDbc`) |
| Checkout Session metadata | Stripe Dashboard → Payments → newest session → Metadata | `clerkUserId`, `tier`, `billing` all populated |
If any check fails: see Troubleshooting below.
---
## §5.3 Troubleshooting (smoke-test failure modes)
| Symptom | Likely cause | Fix |
|---|---|---|
| Pricing button click → 401 alert "Sign in required" | You haven't signed up / signed in yet | Sign up first via the navbar, then retry |
| Pricing button click → 503 "configuration required" | `STRIPE_SECRET_KEY` is still placeholder (`REPLACE_AFTER_ROTATION`) | Paste real `sk_test_*` from `stripe config --list` into `apps/www/.env.local`, restart `next dev` |
| Pricing button → 503 "No active Stripe price found" | `lookup_key` not set on the price (or env var is wrong) | Re-run §5.3 Phase A `stripe prices update` for the missing tier |
| Checkout completes but Clerk metadata stays empty | Webhook listener not running OR signature secret mismatch | Confirm `stripe listen` is up, secret in `.env.local` matches `--print-secret` output, restart `next dev` after pasting |
| `checkout.session.completed [400]` in `stripe listen` log | "Signature verification failed" — listener restarted with a new secret while old one is still in env | Copy fresh secret from listener's startup line, paste into `.env.local`, restart `next dev` |
| Webhook fires but Clerk metadata doesn't update | Session metadata missing `clerkUserId` (rare — only if checkout was created outside this app) | Inspect the offending session's metadata in Stripe Dashboard; the route always sets it on new sessions |
| Stripe customer created but Clerk `stripeCustomerId` not saved | Clerk publicMetadata write failed silently | Check `next dev` logs for Clerk errors; verify `CLERK_SECRET_KEY` is the freshly rotated key, not stale |
---
## §5.3 Phase D — Why no Clerk webhook is needed
The **lazy-create pattern** attaches a Stripe Customer at first paid
checkout, not at user creation. As a result:
- No `user.created` Clerk webhook handler is needed.
- `apps/www/app/api/webhooks/clerk/` is **not** created in §5.3.
- `CLERK_WEBHOOK_SECRET` in `.env.local` stays as
`REPLACE_AFTER_WEBHOOK_REGISTERED` indefinitely (or remove the line).
**Trade-off:** a user who signs up and never reaches checkout has no
`stripeCustomerId` in their publicMetadata. That's correct — they
shouldn't have a Stripe customer record until they convert. The
checkout route's `ensureStripeCustomer` handles the gap on first pay.
If we later decide to support pre-paid features (e.g. trial without a
card), pre-creating Stripe customers eagerly via Clerk webhook becomes
useful. For now, lazy-create is simpler and avoids the
"orphan Stripe customer" failure mode.
---
## Out of scope (locked for §5.4+)
- **Pricing.tsx CTA gating** — §5.4 will migrate from `POST` + `window.open` to:
- Signed-in users: GET `/api/stripe/checkout?tier=...&billing=...` → 303 to Stripe Checkout
- Signed-out users: open `<SignUpButton>` modal with `forceRedirectUrl=/api/stripe/checkout?tier=...&billing=...` so they land on Stripe Checkout immediately after sign-up
- **POST backward-compat shim removal** — once §5.4 migrates Pricing.tsx, the POST handler in `/api/stripe/checkout/route.ts` is dead code and should be deleted.
- **FR Pass8-A logo fix** — §5.5 (filename mismatch in HeroVisual asset reference, root cause isolated in §5.0 brief).
- **§5.6 final verification** — full landing-page screenshot diff vs. Sesija D baseline + visual confirmation that Hive DS hasn't regressed.
- **Live keys + production webhook endpoint** — Marko-side ponedeljak 14:00.
---
## Day-2 polish backlog (post §5.6)
| Item | Severity | Source | Notes |
|---|---|---|---|
| Remove POST shim from `/api/stripe/checkout` | low | §5.3 Phase C | Once §5.4 migrates Pricing.tsx to GET, the POST handler is dead code — delete the function + its `runCheckout` POST branch. |
| Stripe customer email sync | medium | observed in route | `ensureStripeCustomer` sets email from Clerk's `primaryEmailAddress` once. If user changes email in Clerk, Stripe customer email goes stale. Consider a Clerk `user.updated` webhook OR periodic sync (low priority — Stripe's email is mainly used for receipts, which can be customized). |
| `subscriptionStatus: 'paused'` collapsed to `canceled` | low | `webhooks/stripe/route.ts` `mapStatus` | Stripe's `paused` is not technically canceled. If we ever support paid-tier pausing in the UI, distinguish. Current collapse is the "UI shows it as inactive" semantic. |
| Webhook idempotency | medium | best practice | Stripe can deliver the same event twice (rare but documented). Current handlers are idempotent for the metadata fields they touch (last-write wins is fine for status). If we add side effects like email sends, gate on `event.id` dedup table. |
| Production env vars | high (pre-launch) | §5.3 Phase B | Replace test-mode keys with `sk_live_*` + production `whsec_*`. Production prices may differ from test-mode prices — re-provision via CLI on the live account, capture new IDs, update production env. |
| Subscription receipts customization | low | Stripe default | Stripe sends receipts to `customer.email` by default. Verify with Marko whether the brand wants Stripe-default receipts or a custom outbound. |
---
## §5.3 acceptance criteria
| # | Criterion | Status | Evidence |
|---|---|---|---|
| 1 | Stripe catalog has 2 active products (Pro + Teams) with proper lookup_keys + metadata, no duplicates added | ✅ | Path A applied; `stripe products list --active=true \| grep -i waggle` shows exactly 2 |
| 2 | All 4 prices have `lookup_key` matching `${tier}_${billing}` and `metadata[tier]` + `metadata[billing]` set | ✅ | `stripe prices list \| jq '.data[] \| select(.lookup_key \| startswith("pro_") or startswith("teams_"))'` confirms |
| 3 | `apps/www/.env.local` has all 4 real `STRIPE_PRICE_*` IDs (no placeholders) | ✅ | gitignored; verified by Marko paste of secret + this commit's env update |
| 4 | `apps/www/.env.local.example` documents the 4 keys + CLI provisioning command | ✅ | Commit `0147d6c` |
| 5 | `/api/stripe/checkout` enforces auth, lazy-creates Stripe Customer, resolves price by lookup_key with env-pin fallback | ✅ | Commit `a087cf6` |
| 6 | `/api/webhooks/stripe` verifies signature + handles 3 event types + maps to Clerk publicMetadata | ✅ | Commit `a087cf6` |
| 7 | No Clerk webhook handler created (lazy-create pattern) | ✅ | `apps/www/app/api/webhooks/clerk/` does not exist |
| 8 | TypeScript compiles clean (`npx tsc --noEmit`) | ✅ | Verified post-Phase-C |
| 9 | Manifest documents Phase B (local listener) + Phase E (smoke test) + production webhook plan | ✅ | This document |
| 10 | Smoke test executable end-to-end | ⏸ Pending | Requires Marko to run with `stripe listen` — see Phase E walkthrough above |
**9 / 10 PASS · 1 pending Marko-side smoke run.**

View File

@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach } from 'vitest';
import BrandPersonasCard from '../app/_components/BrandPersonasCard';
import { personas, type PersonaSlug } from '../app/_data/personas';
afterEach(() => {
cleanup();
});
describe('BrandPersonasCard — landing variant', () => {
it('renders 13 persona tiles + 3 filler tiles by default', () => {
render(<BrandPersonasCard />);
const tiles = screen.getAllByTestId(/^persona-tile-/);
expect(tiles).toHaveLength(13);
const fillers = screen.getAllByTestId('brand-personas-filler');
expect(fillers).toHaveLength(3);
// Fillers must be marked aria-hidden to stay out of AT navigation.
for (const filler of fillers) {
expect(filler).toHaveAttribute('aria-hidden', 'true');
}
});
it('renders correct title + role copy for all 13 personas (verbatim from locked decision)', () => {
render(<BrandPersonasCard />);
for (const persona of personas) {
const tile = screen.getByTestId(`persona-tile-${persona.slug}`);
const scope = within(tile);
expect(scope.getByText(persona.title)).toBeInTheDocument();
expect(scope.getByText(persona.role)).toBeInTheDocument();
}
});
it('fires onPersonaClick with the correct slug when a tile is clicked', () => {
const handler = vi.fn<(slug: PersonaSlug) => void>();
render(<BrandPersonasCard onPersonaClick={handler} />);
const hunterTile = screen.getByTestId('persona-tile-hunter');
const button = within(hunterTile).getByRole('button', {
name: /Waggle The Hunter bee mascot/i,
});
fireEvent.click(button);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith('hunter');
});
it('fires onTileHover with the correct slug on mouseenter', () => {
const handler = vi.fn<(slug: PersonaSlug) => void>();
render(<BrandPersonasCard onTileHover={handler} />);
const connectorTile = screen.getByTestId('persona-tile-connector');
fireEvent.mouseEnter(connectorTile);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith('connector');
});
it('renders the cta slot below the grid', () => {
render(
<BrandPersonasCard
cta={<a href="https://example.test/cta">Meet the hive</a>}
/>,
);
const ctaSlot = screen.getByTestId('brand-personas-cta');
expect(ctaSlot).toBeInTheDocument();
expect(
within(ctaSlot).getByRole('link', { name: /Meet the hive/i }),
).toHaveAttribute('href', 'https://example.test/cta');
});
it('hides filler tiles when showFillerTiles={false}', () => {
render(<BrandPersonasCard showFillerTiles={false} />);
expect(screen.queryAllByTestId('brand-personas-filler')).toHaveLength(0);
// Persona tiles remain intact — feature is filler-scoped.
expect(screen.getAllByTestId(/^persona-tile-/)).toHaveLength(13);
});
it('flips to a placeholder when a persona asset fails to load', () => {
render(<BrandPersonasCard />);
const writerTile = screen.getByTestId('persona-tile-writer');
const img = within(writerTile).getByRole('img', {
name: /Waggle The Writer bee mascot/i,
});
// Simulate the 404/onerror path that fires when an asset is missing
// (e.g., mid-regen Task #24 state before the new PNG lands).
fireEvent.error(img);
expect(writerTile).toHaveAttribute('data-placeholder', 'true');
expect(
screen.getByTestId('persona-placeholder-writer'),
).toBeInTheDocument();
// Role copy must stay readable even while the image degrades.
expect(
within(writerTile).getByText(
'Shapes the story the memory wants to tell.',
),
).toBeInTheDocument();
});
it('supports keyboard activation (Enter + Space) when onPersonaClick is provided', () => {
const handler = vi.fn<(slug: PersonaSlug) => void>();
render(<BrandPersonasCard onPersonaClick={handler} />);
const teamTile = screen.getByTestId('persona-tile-team');
const button = within(teamTile).getByRole('button');
fireEvent.keyDown(button, { key: 'Enter' });
fireEvent.keyDown(button, { key: ' ' });
// A non-activation key must not fire the handler.
fireEvent.keyDown(button, { key: 'Tab' });
expect(handler).toHaveBeenCalledTimes(2);
expect(handler).toHaveBeenNthCalledWith(1, 'team');
expect(handler).toHaveBeenNthCalledWith(2, 'team');
});
it('honours custom heading + subtitle overrides', () => {
render(
<BrandPersonasCard
heading="Internal reference"
subtitle="Brand canon for the team."
/>,
);
expect(
screen.getByRole('heading', { name: 'Internal reference' }),
).toBeInTheDocument();
expect(screen.getByText('Brand canon for the team.')).toBeInTheDocument();
});
});
describe('BrandPersonasCard — compact variant', () => {
it('renders the compact stub without errors and exposes the data-variant hook', () => {
render(<BrandPersonasCard variant="compact" />);
const stub = screen.getByTestId('brand-personas-card-compact');
expect(stub).toBeInTheDocument();
expect(stub).toHaveAttribute('data-variant', 'compact');
// Landing DOM must not leak into compact path.
expect(
screen.queryByTestId('brand-personas-card'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import Pricing from '../app/_components/Pricing';
afterEach(() => {
cleanup();
window.history.pushState({}, '', '/');
});
describe('Pricing', () => {
it('uses the canonical GET checkout URL for Team checkout', () => {
render(<Pricing />);
const monthlyCta = screen.getByRole('link', {
name: 'landing.pricing.tiers.teams.cta',
});
expect(monthlyCta).toHaveAttribute(
'href',
'/api/stripe/checkout?tier=teams&billing=monthly',
);
fireEvent.click(
screen.getByRole('button', { name: /landing\.pricing\.toggle\.annual/ }),
);
const annualCta = screen.getByRole('link', {
name: 'landing.pricing.tiers.teams.cta',
});
expect(annualCta).toHaveAttribute(
'href',
'/api/stripe/checkout?tier=teams&billing=annual',
);
});
it('shows a retry path after a cancelled checkout', async () => {
window.history.pushState({}, '', '/?checkout=cancelled#pricing');
render(<Pricing />);
const notice = await screen.findByRole('status');
expect(notice).toHaveTextContent('landing.pricing.notices.cancelled');
expect(
screen.getByRole('link', {
name: 'landing.pricing.notices.retry',
}),
).toHaveAttribute(
'href',
'/api/stripe/checkout?tier=teams&billing=monthly',
);
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const workflow = () =>
readFileSync(
join(process.cwd(), '..', '..', '.github', 'workflows', 'deploy-www.yml'),
'utf8',
);
describe('public-site deployment workflow', () => {
it('deploys the dynamic Next app with Vercel instead of GitHub Pages static artifacts', () => {
const source = workflow();
expect(source).toContain('vercel pull');
expect(source).toContain('vercel build');
expect(source).toContain('vercel deploy --prebuilt --prod');
expect(source).toContain('VERCEL_TOKEN');
expect(source).toContain('VERCEL_ORG_ID');
expect(source).toContain('VERCEL_PROJECT_ID');
expect(source).not.toContain('upload-pages-artifact');
expect(source).not.toContain('deploy-pages');
expect(source).not.toContain('apps/www/dist');
});
});

View File

@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import DownloadCTA from '../app/_components/DownloadCTA';
import DownloadPage from '../app/download/page';
import { detectOSFromUserAgent } from '../app/_lib/os-detection';
afterEach(() => {
cleanup();
});
describe('download path', () => {
it('routes public download CTAs to the controlled download page', () => {
render(<DownloadCTA section="hero" />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/download');
expect(screen.getByRole('link')).not.toHaveAttribute('target');
});
it('does not send visitors directly to an empty GitHub Releases page', () => {
render(<DownloadPage />);
expect(
screen.getByRole('heading', { name: 'Download Waggle' }),
).toBeInTheDocument();
expect(
screen.getByText('Windows and macOS installers are being prepared for the signed public release.'),
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'View source on GitHub' }),
).toHaveAttribute('href', 'https://github.com/marolinik/waggle-os');
expect(
screen.queryByRole('link', { name: /releases/i }),
).not.toBeInTheDocument();
});
it('does not label mobile visitors as desktop operating systems', () => {
expect(
detectOSFromUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
),
).toBeNull();
expect(
detectOSFromUserAgent(
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/126.0.0.0 Mobile Safari/537.36',
),
).toBeNull();
expect(
detectOSFromUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 Safari/605.1.15',
),
).toBe('macOS');
expect(
detectOSFromUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36',
),
).toBe('Windows');
});
});

View File

@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest';
import type { ReactElement, ReactNode } from 'react';
vi.mock('next/font/google', () => ({
Hanken_Grotesk: () => ({ variable: '__font_hanken' }),
JetBrains_Mono: () => ({ variable: '__font_mono' }),
}));
vi.mock('next-intl/server', () => ({
getLocale: vi.fn(async () => 'en'),
getMessages: vi.fn(async () => ({})),
}));
vi.mock('@clerk/nextjs', () => ({
ClerkProvider: ({ children }: { children: ReactNode }) => children,
}));
vi.mock('@clerk/themes', () => ({
dark: {},
}));
import RootLayout from '../app/layout';
describe('RootLayout', () => {
it('keeps the progressive-enhancement js class as an intentional hydration mismatch', async () => {
const tree = (await RootLayout({
children: <main>content</main>,
})) as ReactElement<{
className: string;
suppressHydrationWarning?: boolean;
children: ReactNode;
}>;
expect(tree.type).toBe('html');
expect(tree.props.className).toBe('scroll-smooth __font_hanken __font_mono');
expect(tree.props.className).not.toContain(' js');
expect(tree.props.suppressHydrationWarning).toBe(true);
});
});

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const legalFiles = [
'terms/page.tsx',
'privacy/page.tsx',
'cookies/page.tsx',
'eu-ai-act/page.tsx',
] as const;
const launchBlockingCopy = [
/Day-0 placeholder text/i,
/\[Day-0 launch date\]/i,
/to be filled before public launch/i,
/Pro or Teams/i,
/\[to be designated/i,
] as const;
describe('legal pages', () => {
it('do not expose launch placeholders or retired tier copy', () => {
for (const file of legalFiles) {
const source = readFileSync(
join(process.cwd(), 'app', '(legal)', file),
'utf8',
);
for (const pattern of launchBlockingCopy) {
expect(source, `${file} should not match ${pattern}`).not.toMatch(
pattern,
);
}
}
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { config } from '../middleware';
describe('www Clerk middleware boundary', () => {
it('protects only identity and server-owned flows', () => {
expect(config.matcher).toEqual([
'/account(.*)',
'/sign-in(.*)',
'/sign-up(.*)',
'/(api|trpc)(.*)',
]);
});
});

View File

@@ -0,0 +1,45 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
import type { ReactNode } from 'react';
/**
* jsdom does not implement `matchMedia`; our component queries
* `prefers-reduced-motion` via plain CSS, but consumer code using matchMedia
* (existing `Pricing` component paths, etc.) still needs a shim during tests.
*/
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
/**
* Mock next-intl for unit tests. The `t()` function returns the namespaced
* key (with ICU `{var}` placeholders interpolated), so tests can assert on
* deterministic strings without needing a real `messages/en.json` round-trip.
*
* BrandPersonasCard tests check persona-data text (from `_data/personas.ts`,
* not i18n) and passed-in prop overrides — never the default heading/
* subtitle from i18n — so this mock is safe.
*/
vi.mock('next-intl', () => ({
useTranslations: (namespace?: string) => {
return (key: string, params?: Record<string, string | number>) => {
const fullKey = namespace ? `${namespace}.${key}` : key;
if (!params) return fullKey;
return Object.entries(params).reduce(
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
fullKey,
);
};
},
NextIntlClientProvider: ({ children }: { children: ReactNode }) => children,
}));

View File

@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const clerkMocks = vi.hoisted(() => ({
auth: vi.fn(),
getUser: vi.fn(),
updateUserMetadata: vi.fn(),
}));
const stripeMocks = vi.hoisted(() => ({
checkoutSessionsCreate: vi.fn(),
customersCreate: vi.fn(),
pricesList: vi.fn(),
}));
vi.mock('@clerk/nextjs/server', () => ({
auth: clerkMocks.auth,
clerkClient: vi.fn(async () => ({
users: {
getUser: clerkMocks.getUser,
updateUserMetadata: clerkMocks.updateUserMetadata,
},
})),
}));
vi.mock('stripe', () => ({
default: vi.fn().mockImplementation(() => ({
checkout: {
sessions: {
create: stripeMocks.checkoutSessionsCreate,
},
},
customers: {
create: stripeMocks.customersCreate,
},
prices: {
list: stripeMocks.pricesList,
},
})),
}));
import { GET } from '../app/api/stripe/checkout/route';
describe('/api/stripe/checkout', () => {
beforeEach(() => {
process.env.STRIPE_SECRET_KEY = 'sk_test_checkout';
process.env.STRIPE_PRICE_TEAMS_ANNUAL = 'price_team_annual';
clerkMocks.auth.mockResolvedValue({ userId: 'user_123' });
clerkMocks.getUser.mockResolvedValue({
publicMetadata: { stripeCustomerId: 'cus_existing' },
primaryEmailAddress: { emailAddress: 'team@example.test' },
});
stripeMocks.checkoutSessionsCreate.mockResolvedValue({
url: 'https://checkout.stripe.test/session',
});
});
afterEach(() => {
vi.clearAllMocks();
delete process.env.STRIPE_SECRET_KEY;
delete process.env.STRIPE_PRICE_TEAMS_ANNUAL;
});
it('sends cancelled checkouts back to the homepage pricing section', async () => {
const res = await GET(
new Request(
'https://waggle.example/api/stripe/checkout?tier=teams&billing=annual',
),
);
expect(res.status).toBe(303);
expect(stripeMocks.checkoutSessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
cancel_url: 'https://waggle.example/?checkout=cancelled#pricing',
}),
);
});
it('sends signed-out users to sign-in and preserves the checkout target', async () => {
clerkMocks.auth.mockResolvedValueOnce({ userId: null });
const res = await GET(
new Request(
'https://waggle.example/api/stripe/checkout?tier=teams&billing=monthly',
),
);
expect(res.status).toBe(303);
expect(res.headers.get('location')).toBe(
'https://waggle.example/sign-in?redirect_url=%2Fapi%2Fstripe%2Fcheckout%3Ftier%3Dteams%26billing%3Dmonthly',
);
expect(stripeMocks.checkoutSessionsCreate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,138 @@
import type { Metadata } from 'next';
import type { CSSProperties } from 'react';
export const metadata: Metadata = {
title: 'Cookie Policy — Waggle',
description:
'Cookie policy for Waggle OS by Egzakta Group d.o.o.',
robots: { index: false, follow: false },
};
export default function CookiesPage() {
return (
<article>
<h1 style={h1Style}>Cookie Policy</h1>
<p style={metaLineStyle}>
<strong>Effective date:</strong> July 8, 2026 · <strong>Last updated:</strong> July 8, 2026
</p>
<p style={paragraphStyle}>
This Cookie Policy explains how Waggle OS uses cookies and similar
technologies on its web properties (the marketing site at
waggle-os.ai and any in-product web views).
</p>
<h2 style={h2Style}>1. What cookies are</h2>
<p style={paragraphStyle}>
Cookies are small text files stored by your browser when you visit a
website. They allow the site to remember information about your
visit (e.g., your login state). Similar technologies include local
storage, session storage, and pixels.
</p>
<h2 style={h2Style}>2. Cookies we use</h2>
<h3 style={h3Style}>Strictly necessary</h3>
<p style={paragraphStyle}>
These cookies are required for the Service to function and cannot be
disabled.
</p>
<ul style={listStyle}>
<li style={listItemStyle}>
<strong>Clerk session cookies</strong> keep you logged in. Set by{' '}
<code>clerk.waggle-os.ai</code>. Cleared on logout.
</li>
<li style={listItemStyle}>
<strong>CSRF protection</strong> prevents cross-site request
forgery. Cleared on tab close.
</li>
</ul>
<h3 style={h3Style}>Analytics (opt-in)</h3>
<p style={paragraphStyle}>
These cookies are loaded only if you opt in via Settings Privacy
&ldquo;Allow anonymous product analytics.&rdquo; Default is opt-out.
</p>
<ul style={listStyle}>
<li style={listItemStyle}>
<strong>PostHog cookies</strong> (<code>ph_*</code>) capture
anonymous usage events for product improvement. We do not use
PostHog session recordings, do not capture form inputs, and do not
link analytics to your account email.
</li>
</ul>
<h2 style={h2Style}>3. No advertising or social-media tracking</h2>
<p style={paragraphStyle}>
We do not use third-party advertising networks, social-media pixels
(Meta, TikTok, X), or cross-site tracking. We do not sell or share
data with advertisers.
</p>
<h2 style={h2Style}>4. Managing cookies</h2>
<p style={paragraphStyle}>
You can clear cookies through your browser settings. For granular
control of the analytics cookie, use Settings Privacy in the
Waggle product.
</p>
<h2 style={h2Style}>5. Changes to this policy</h2>
<p style={paragraphStyle}>
We may update this Cookie Policy. Material changes will be announced
via the product and email.
</p>
<h2 style={h2Style}>6. Contact</h2>
<p style={paragraphStyle}>
Email: <strong>privacy@egzakta.com</strong>
</p>
</article>
);
}
const h1Style: CSSProperties = {
fontSize: 'clamp(28px, 4vw, 36px)',
fontWeight: 700,
marginBottom: 24,
color: 'var(--hive-50, #f6f1e4)',
};
const h2Style: CSSProperties = {
fontSize: 'clamp(18px, 2.4vw, 22px)',
fontWeight: 600,
marginTop: 32,
marginBottom: 12,
color: 'var(--hive-50, #f6f1e4)',
};
const h3Style: CSSProperties = {
fontSize: 16,
fontWeight: 600,
marginTop: 16,
marginBottom: 8,
color: 'var(--hive-100, #d8cfba)',
};
const metaLineStyle: CSSProperties = {
fontSize: 13,
color: 'var(--hive-300, #948a73)',
marginBottom: 24,
};
const paragraphStyle: CSSProperties = {
fontSize: 15,
lineHeight: 1.7,
color: 'var(--hive-200, #d8cfba)',
marginBottom: 16,
};
const listStyle: CSSProperties = {
paddingLeft: 24,
marginBottom: 16,
color: 'var(--hive-200, #d8cfba)',
};
const listItemStyle: CSSProperties = {
fontSize: 15,
lineHeight: 1.7,
marginBottom: 8,
};

View File

@@ -0,0 +1,208 @@
import type { Metadata } from 'next';
import type { CSSProperties } from 'react';
export const metadata: Metadata = {
title: 'EU AI Act Statement — Waggle',
description:
'EU AI Act compliance statement for Waggle OS by Egzakta Group d.o.o.',
robots: { index: false, follow: false },
};
export default function EuAiActPage() {
return (
<article>
<h1 style={h1Style}>EU AI Act Statement</h1>
<p style={metaLineStyle}>
<strong>Effective date:</strong> July 8, 2026 · <strong>Last updated:</strong> July 8, 2026
</p>
<p style={paragraphStyle}>
Egzakta Group d.o.o. (&ldquo;we&rdquo;) publishes this statement to
describe how Waggle OS relates to Regulation (EU) 2024/1689
(&ldquo;EU AI Act&rdquo;). Our intent is to be a transparent
participant in the AI value chain, even as the EU AI Act&rsquo;s
provisions phase in through 2026.
</p>
<h2 style={h2Style}>1. Our role in the AI value chain</h2>
<p style={paragraphStyle}>
Waggle OS is a <strong>deployer</strong> (sometimes called
&ldquo;deployer of AI systems&rdquo;) under EU AI Act terminology
when end-users install the Waggle desktop application and use it
for their own purposes. Egzakta is <strong>not</strong> a provider
of general-purpose AI models (GPAI providers defined in Article
51). Waggle OS routes user requests to third-party AI providers
(Anthropic, OpenAI, Mistral, and others). The underlying GPAI
providers fulfill the obligations applicable to them.
</p>
<h2 style={h2Style}>2. Article 50 transparency obligations</h2>
<p style={paragraphStyle}>
We comply with the transparency obligations of Article 50 by:
</p>
<ul style={listStyle}>
<li style={listItemStyle}>
<strong>Clearly labeling AI-generated output</strong>: the Waggle
UI marks AI-generated responses as such; the user always knows
they are interacting with an AI system.
</li>
<li style={listItemStyle}>
<strong>Disclosing system prompts on request</strong>: users can
view the active persona&rsquo;s system prompt in Settings
Personas selected persona System Prompt.
</li>
<li style={listItemStyle}>
<strong>No deepfake generation</strong>: Waggle OS does not
generate synthetic images, audio, or video that could be confused
with authentic media.
</li>
</ul>
<h2 style={h2Style}>3. Article 5 prohibited practices</h2>
<p style={paragraphStyle}>
Waggle OS does not implement, encourage, or facilitate any
prohibited AI practice under Article 5, including:
</p>
<ul style={listStyle}>
<li style={listItemStyle}>
Subliminal manipulation or exploitation of vulnerabilities.
</li>
<li style={listItemStyle}>
Social scoring of natural persons.
</li>
<li style={listItemStyle}>
Real-time remote biometric identification in publicly accessible
spaces.
</li>
<li style={listItemStyle}>
Emotion inference in workplace or education contexts.
</li>
</ul>
<h2 style={h2Style}>4. High-risk AI systems</h2>
<p style={paragraphStyle}>
Waggle OS in its default consumer configuration is not a high-risk
AI system under Annex III. If users deploy Waggle OS for high-risk
purposes (e.g., employment screening, credit scoring), the deployer
is responsible for fulfilling the obligations applicable to that
high-risk system.
</p>
<h2 style={h2Style}>5. General-purpose AI (GPAI) considerations</h2>
<p style={paragraphStyle}>
Waggle OS depends on GPAI models provided by third parties. Those
providers publish their own EU AI Act compliance information:
</p>
<ul style={listStyle}>
<li style={listItemStyle}>
Anthropic:{' '}
<a href="https://www.anthropic.com/eu-ai-act" style={linkStyle}>
anthropic.com/eu-ai-act
</a>
</li>
<li style={listItemStyle}>
OpenAI:{' '}
<a href="https://openai.com/eu-ai-act" style={linkStyle}>
openai.com/eu-ai-act
</a>
</li>
<li style={listItemStyle}>
Mistral:{' '}
<a href="https://mistral.ai/eu-ai-act" style={linkStyle}>
mistral.ai/eu-ai-act
</a>
</li>
</ul>
<h2 style={h2Style}>6. Risk management and documentation</h2>
<p style={paragraphStyle}>
Egzakta maintains internal documentation of:
</p>
<ul style={listStyle}>
<li style={listItemStyle}>
Models routed to and their provenance.
</li>
<li style={listItemStyle}>
Safety testing for the persona system and behavioral spec.
</li>
<li style={listItemStyle}>
The evolution subsystem (see arxiv paper, pending submission).
</li>
<li style={listItemStyle}>
User-facing transparency mechanisms.
</li>
</ul>
<p style={paragraphStyle}>
We will publish a public AI risk management summary by Article
50&rsquo;s effective date (2026-08-02 for Article 50 obligations).
</p>
<h2 style={h2Style}>7. Data protection alignment</h2>
<p style={paragraphStyle}>
Personal data handling is governed by our{' '}
<a href="/privacy" style={linkStyle}>
Privacy Policy
</a>{' '}
and the GDPR. We do not train AI models on user data without
explicit consent. Local memory data never leaves the user&rsquo;s
device unless they explicitly enable team sharing (Teams tier).
</p>
<h2 style={h2Style}>
8. Contact for data subject and EU AI Act inquiries
</h2>
<p style={paragraphStyle}>
Email: <strong>ai-compliance@egzakta.com</strong>
<br />
Data Protection Officer: <strong>dpo@egzakta.com</strong>
<br />
Representative inquiries under Article 25:{' '}
<strong>ai-compliance@egzakta.com</strong>
</p>
</article>
);
}
const h1Style: CSSProperties = {
fontSize: 'clamp(28px, 4vw, 36px)',
fontWeight: 700,
marginBottom: 24,
color: 'var(--hive-50, #f6f1e4)',
};
const h2Style: CSSProperties = {
fontSize: 'clamp(18px, 2.4vw, 22px)',
fontWeight: 600,
marginTop: 32,
marginBottom: 12,
color: 'var(--hive-50, #f6f1e4)',
};
const metaLineStyle: CSSProperties = {
fontSize: 13,
color: 'var(--hive-300, #948a73)',
marginBottom: 24,
};
const paragraphStyle: CSSProperties = {
fontSize: 15,
lineHeight: 1.7,
color: 'var(--hive-200, #d8cfba)',
marginBottom: 16,
};
const listStyle: CSSProperties = {
paddingLeft: 24,
marginBottom: 16,
color: 'var(--hive-200, #d8cfba)',
};
const listItemStyle: CSSProperties = {
fontSize: 15,
lineHeight: 1.7,
marginBottom: 8,
};
const linkStyle: CSSProperties = {
color: 'var(--honey-400, #f6c45a)',
textDecoration: 'underline',
};

View File

@@ -0,0 +1,29 @@
import type { CSSProperties, ReactNode } from 'react';
import Navbar from '../_components/Navbar';
import Footer from '../_components/Footer';
/**
* Layout for the legal route group: /privacy, /terms, /cookies, /eu-ai-act.
*
* Wraps each legal page with the same Navbar + Footer chrome as the landing
* page so the legal pages don't feel like a separate microsite. The route
* group `(legal)` is invisible in the URL — pages are reachable at their
* top-level paths.
*/
export default function LegalLayout({ children }: { children: ReactNode }) {
return (
<>
<Navbar />
<main style={mainStyle}>{children}</main>
<Footer />
</>
);
}
const mainStyle: CSSProperties = {
maxWidth: 760,
margin: '120px auto 96px',
padding: '0 24px',
fontFamily: "var(--sans)",
color: 'var(--hive-100, #ece3d0)',
};

View File

@@ -0,0 +1,208 @@
import type { Metadata } from 'next';
import type { CSSProperties } from 'react';
export const metadata: Metadata = {
title: 'Privacy Policy — Waggle',
description:
'Privacy policy for Waggle OS by Egzakta Group d.o.o.',
robots: { index: false, follow: false },
};
export default function PrivacyPage() {
return (
<article>
<h1 style={h1Style}>Privacy Policy</h1>
<p style={metaLineStyle}>
<strong>Effective date:</strong> July 8, 2026 · <strong>Last updated:</strong> July 8, 2026
</p>
<p style={paragraphStyle}>
Waggle OS is provided by Egzakta Group d.o.o. (&ldquo;Egzakta&rdquo;,
&ldquo;we&rdquo;, &ldquo;us&rdquo;), a company registered in the
Republic of Serbia. This Privacy Policy explains what personal data
we collect, how we use it, and your rights.
</p>
<h2 style={h2Style}>1. Data we collect</h2>
<ul style={listStyle}>
<li style={listItemStyle}>
<strong>Account data</strong>: when you sign up via Clerk, we collect
your email address, display name, and optional profile information.
Clerk Inc. processes this data as an authentication sub-processor
under their own privacy policy (
<a href="https://clerk.com/privacy" style={linkStyle}>
clerk.com/privacy
</a>
).
</li>
<li style={listItemStyle}>
<strong>Subscription data</strong>: when you upgrade to Team,
Stripe Inc. processes your payment information. We never see or
store your card details Stripe returns only a customer ID and
subscription status to us (
<a href="https://stripe.com/privacy" style={linkStyle}>
stripe.com/privacy
</a>
).
</li>
<li style={listItemStyle}>
<strong>Product analytics</strong>: with your opt-in (default off),
we collect anonymous usage events (onboarding completion, feature
interactions) via PostHog Inc. to improve the product. You can opt
out at any time in Settings Privacy (
<a href="https://posthog.com/privacy" style={linkStyle}>
posthog.com/privacy
</a>
).
</li>
<li style={listItemStyle}>
<strong>Local memory</strong>: Waggle OS stores your conversations,
memories, and harvested content <strong>locally on your device</strong>{' '}
in a SQLite database (the <code>.waggle/</code> directory). We do
not transmit this data to our servers. If you choose to enable
shared team workspaces (Teams tier), encrypted memory bundles are
stored on our infrastructure for synchronization; you control which
workspaces sync.
</li>
</ul>
<h2 style={h2Style}>2. How we use your data</h2>
<ul style={listStyle}>
<li style={listItemStyle}>
Provide the Service (authentication, subscription management, support).
</li>
<li style={listItemStyle}>
Improve the Service (analytics where you&rsquo;ve opted in).
</li>
<li style={listItemStyle}>
Comply with legal obligations (tax records, fraud prevention).
</li>
<li style={listItemStyle}>
We do not sell your personal data and do not use it for advertising.
</li>
</ul>
<h2 style={h2Style}>3. Where your data lives</h2>
<ul style={listStyle}>
<li style={listItemStyle}>
Account and subscription data: Clerk and Stripe (US- and EU-region
processors).
</li>
<li style={listItemStyle}>
Analytics (opt-in only): PostHog (US-region).
</li>
<li style={listItemStyle}>
Memory and conversations: your device. Optionally your Teams
workspace bundle.
</li>
<li style={listItemStyle}>
Egzakta&rsquo;s own systems: minimal account and billing metadata in
encrypted storage.
</li>
</ul>
<h2 style={h2Style}>4. Your rights (GDPR and similar regimes)</h2>
<p style={paragraphStyle}>
You have the right to access, rectify, port, restrict, or delete your
personal data, and to object to processing. To exercise these rights,
email <strong>privacy@egzakta.com</strong>. We aim to respond within
30 days.
</p>
<p style={paragraphStyle}>
Local memory data: you can erase all locally-stored data at any time
via Settings Privacy Erase All Data. This is irreversible and
does not require contacting us.
</p>
<h2 style={h2Style}>5. Retention</h2>
<ul style={listStyle}>
<li style={listItemStyle}>
Account data: retained while your account is active plus 90 days
after deletion for billing reconciliation.
</li>
<li style={listItemStyle}>
Subscription data: retained per Stripe&rsquo;s record-keeping
requirements (typically 7 years for accounting).
</li>
<li style={listItemStyle}>
Analytics (opt-in only): retained 12 months.
</li>
<li style={listItemStyle}>
Local memory: retained until you delete it. Egzakta has no access.
</li>
</ul>
<h2 style={h2Style}>6. Cookies and tracking</h2>
<p style={paragraphStyle}>
We use strictly-necessary cookies for authentication (Clerk session)
and, with your opt-in, analytics cookies (PostHog). See our{' '}
<a href="/cookies" style={linkStyle}>
Cookie Policy
</a>
.
</p>
<h2 style={h2Style}>7. Changes to this policy</h2>
<p style={paragraphStyle}>
We may update this policy. Material changes will be announced via the
product and email. Continued use after a change means you accept the
updated terms.
</p>
<h2 style={h2Style}>8. Contact</h2>
<p style={paragraphStyle}>
Egzakta Group d.o.o.
<br />
Registered office: Belgrade, Republic of Serbia
<br />
Email: <strong>privacy@egzakta.com</strong>
<br />
Data Protection Officer: <strong>dpo@egzakta.com</strong>
</p>
</article>
);
}
const h1Style: CSSProperties = {
fontSize: 'clamp(28px, 4vw, 36px)',
fontWeight: 700,
marginBottom: 24,
color: 'var(--hive-50, #f6f1e4)',
};
const h2Style: CSSProperties = {
fontSize: 'clamp(18px, 2.4vw, 22px)',
fontWeight: 600,
marginTop: 32,
marginBottom: 12,
color: 'var(--hive-50, #f6f1e4)',
};
const metaLineStyle: CSSProperties = {
fontSize: 13,
color: 'var(--hive-300, #948a73)',
marginBottom: 24,
};
const paragraphStyle: CSSProperties = {
fontSize: 15,
lineHeight: 1.7,
color: 'var(--hive-200, #d8cfba)',
marginBottom: 16,
};
const listStyle: CSSProperties = {
paddingLeft: 24,
marginBottom: 16,
color: 'var(--hive-200, #d8cfba)',
};
const listItemStyle: CSSProperties = {
fontSize: 15,
lineHeight: 1.7,
marginBottom: 8,
};
const linkStyle: CSSProperties = {
color: 'var(--honey-400, #f6c45a)',
textDecoration: 'underline',
};

View File

@@ -0,0 +1,198 @@
import type { Metadata } from 'next';
import type { CSSProperties } from 'react';
export const metadata: Metadata = {
title: 'Terms of Service — Waggle',
description:
'Terms of Service for Waggle OS by Egzakta Group d.o.o.',
robots: { index: false, follow: false },
};
export default function TermsPage() {
return (
<article>
<h1 style={h1Style}>Terms of Service</h1>
<p style={metaLineStyle}>
<strong>Effective date:</strong> July 8, 2026 · <strong>Last updated:</strong> July 8, 2026
</p>
<p style={paragraphStyle}>
These Terms of Service (&ldquo;Terms&rdquo;) govern your use of Waggle
OS (the &ldquo;Service&rdquo;), provided by Egzakta Group d.o.o.
(&ldquo;Egzakta&rdquo;, &ldquo;we&rdquo;, &ldquo;us&rdquo;). By
creating an account or installing the Waggle desktop application, you
agree to these Terms.
</p>
<h2 style={h2Style}>1. The Service</h2>
<p style={paragraphStyle}>
Waggle OS is a desktop AI agent platform with persistent local memory.
The Service includes the Waggle desktop application, the cloud-hosted
account infrastructure, and optional team collaboration features.
</p>
<h2 style={h2Style}>2. Account</h2>
<p style={paragraphStyle}>
You must be at least 16 years old to use the Service. You are
responsible for maintaining the confidentiality of your account
credentials. You agree to provide accurate and current information
when creating an account.
</p>
<h2 style={h2Style}>3. Acceptable use</h2>
<p style={paragraphStyle}>You agree not to:</p>
<ul style={listStyle}>
<li style={listItemStyle}>
Use the Service to violate any law or third-party rights.
</li>
<li style={listItemStyle}>
Attempt to reverse-engineer, modify, or interfere with the
Service&rsquo;s protections.
</li>
<li style={listItemStyle}>
Use the Service to generate content that infringes intellectual
property, defames individuals, or violates privacy.
</li>
<li style={listItemStyle}>
Submit data you do not have the right to submit (including
confidential or copyrighted third-party content).
</li>
<li style={listItemStyle}>
Use the Service to develop or train competing AI products.
</li>
</ul>
<h2 style={h2Style}>4. Subscription and billing</h2>
<p style={paragraphStyle}>
Solo tier: free, forever, for all personal use full memory,
Harvest import, unlimited workspaces, and any model with your own
keys. Team tier (USD 49/seat/month): adds shared workspaces and team
collaboration.
</p>
<p style={paragraphStyle}>
Subscriptions are billed in advance through Stripe. Auto-renewal is
on by default; you can cancel at any time in account settings.
Cancellations take effect at the end of the current billing period.
We do not refund partial periods except where required by law.
</p>
<h2 style={h2Style}>5. Trial</h2>
<p style={paragraphStyle}>
A free 15-day trial of all features is available to new accounts. If
you do not subscribe at trial end, your account converts to the Solo
tier and trial features become inaccessible. Trial data is retained
per the{' '}
<a href="/privacy" style={linkStyle}>
Privacy Policy
</a>
.
</p>
<h2 style={h2Style}>6. Intellectual property</h2>
<p style={paragraphStyle}>
You retain ownership of content you create, store, or process via the
Service. You grant Egzakta a limited license to host and process your
content solely to provide the Service.
</p>
<p style={paragraphStyle}>
Egzakta retains all rights in the Service, including source code,
documentation, and marketing materials. Open-source components are
governed by their respective licenses (notably Apache 2.0 for the
hive-mind substrate).
</p>
<h2 style={h2Style}>7. Disclaimer of warranties</h2>
<p style={paragraphStyle}>
The Service is provided &ldquo;as is&rdquo; without warranties of any
kind. We do not warrant that the Service will be uninterrupted,
error-free, or that AI-generated outputs will be accurate, complete,
or fit for any particular purpose. You are responsible for verifying
AI-generated content before acting on it.
</p>
<h2 style={h2Style}>8. Limitation of liability</h2>
<p style={paragraphStyle}>
To the maximum extent permitted by law, Egzakta&rsquo;s total
liability for any claim arising from the Service is limited to the
fees you paid Egzakta in the 12 months preceding the claim. We are
not liable for indirect, incidental, or consequential damages.
</p>
<h2 style={h2Style}>9. Termination</h2>
<p style={paragraphStyle}>
Either party may terminate at any time. We may suspend or terminate
your account for material breach of these Terms. Upon termination,
you may export your data for 30 days; after that, we may delete your
account data in accordance with the Privacy Policy.
</p>
<h2 style={h2Style}>10. Governing law</h2>
<p style={paragraphStyle}>
These Terms are governed by the laws of the Republic of Serbia.
Disputes shall be resolved in the competent courts of Belgrade,
Serbia, except where mandatory consumer protection laws in your
jurisdiction provide otherwise.
</p>
<h2 style={h2Style}>11. Changes to the Terms</h2>
<p style={paragraphStyle}>
We may update these Terms. Material changes will be announced at
least 30 days in advance via the product and email. Continued use
after the change means you accept the updated Terms.
</p>
<h2 style={h2Style}>12. Contact</h2>
<p style={paragraphStyle}>
Egzakta Group d.o.o.
<br />
Registered office: Belgrade, Republic of Serbia
<br />
Email: <strong>legal@egzakta.com</strong>
</p>
</article>
);
}
const h1Style: CSSProperties = {
fontSize: 'clamp(28px, 4vw, 36px)',
fontWeight: 700,
marginBottom: 24,
color: 'var(--hive-50, #f6f1e4)',
};
const h2Style: CSSProperties = {
fontSize: 'clamp(18px, 2.4vw, 22px)',
fontWeight: 600,
marginTop: 32,
marginBottom: 12,
color: 'var(--hive-50, #f6f1e4)',
};
const metaLineStyle: CSSProperties = {
fontSize: 13,
color: 'var(--hive-300, #948a73)',
marginBottom: 24,
};
const paragraphStyle: CSSProperties = {
fontSize: 15,
lineHeight: 1.7,
color: 'var(--hive-200, #d8cfba)',
marginBottom: 16,
};
const listStyle: CSSProperties = {
paddingLeft: 24,
marginBottom: 16,
color: 'var(--hive-200, #d8cfba)',
};
const listItemStyle: CSSProperties = {
fontSize: 15,
lineHeight: 1.7,
marginBottom: 8,
};
const linkStyle: CSSProperties = {
color: 'var(--honey-400, #f6c45a)',
textDecoration: 'underline',
};

View File

@@ -0,0 +1,58 @@
interface BrandMarkProps {
readonly size?: number;
readonly withWordmark?: boolean;
}
/**
* Waggle brand mark: a hive cell (pointy-top hexagon) holding a honey core —
* one memory node in the graph. Pure SVG so it stays crisp at every density
* and inherits no JPEG artifacts (replaces the legacy logo.jpeg raster).
*/
export default function BrandMark({ size = 28, withWordmark = false }: BrandMarkProps) {
const mark = (
<svg
width={size}
height={size}
viewBox="0 0 32 32"
fill="none"
role="img"
aria-label="Waggle"
>
<path
d="M16 3 L27.26 9.5 L27.26 22.5 L16 29 L4.74 22.5 L4.74 9.5 Z"
stroke="var(--honey-500, #e9a52c)"
strokeWidth="2.4"
strokeLinejoin="round"
fill="none"
/>
<path
d="M16 10.8 L20.5 13.4 L20.5 18.6 L16 21.2 L11.5 18.6 L11.5 13.4 Z"
fill="var(--honey-400, #f6c45a)"
/>
</svg>
);
if (!withWordmark) return mark;
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
}}
>
{mark}
<span
style={{
fontSize: 17,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--hive-50, #f6f1e4)',
}}
>
Waggle
</span>
</span>
);
}

View File

@@ -0,0 +1,537 @@
'use client';
import {
useCallback,
useState,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
} from 'react';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import {
HEX_TEXTURE_PATH,
personas,
type Persona,
type PersonaSlug,
} from '../_data/personas';
/**
* 4x4 grid sequence in row-major order. `'filler'` slots occupy top-left,
* top-right, and bottom-right corners. Numbers reference `Persona.order`.
*/
const LANDING_GRID_SEQUENCE: ReadonlyArray<'filler' | number> = [
'filler', 1, 2, 'filler',
3, 4, 5, 6,
7, 8, 9, 10,
11, 12, 13, 'filler',
];
const personaByOrder = new Map<number, Persona>(
personas.map((p) => [p.order, p]),
);
/**
* Per-persona accent — a curated warm ramp (honey / amber / copper / bronze /
* terracotta family; no purple, no gradients). Round-6 palette discipline:
* exactly ONE muted cool note survives (sleeping → night blue, where the hue
* IS the meaning); everything else stays in the warm-Hive family. Each tile
* gets its hue on the title, top hairline, and hover border/glow so the grid
* reads as a cast of characters, not a spreadsheet. Hues are chosen so
* horizontally/vertically adjacent tiles (4-col landing grid) never repeat,
* and a few map to meaning (confused → terracotta flag, researcher/team →
* deep bronze, analyst/architect → copper).
*/
const PERSONA_ACCENTS: Readonly<Record<PersonaSlug, string>> = {
hunter: '#f6c45a',
researcher: '#c07e16',
analyst: '#d98a3d',
connector: '#f2b950',
architect: '#d98a3d',
builder: '#e9a52c',
writer: '#e0916f',
orchestrator: '#e9a52c',
marketer: '#f6c45a',
team: '#c07e16',
celebrating: '#f9d27e',
confused: '#db8068',
sleeping: '#86a9d1',
};
export interface BrandPersonasCardProps {
/** Optional uppercase kicker rendered above the heading (e.g. "Built for"). */
eyebrow?: string;
heading?: string;
subtitle?: string;
showFillerTiles?: boolean;
variant?: 'landing' | 'compact';
onTileHover?: (slug: PersonaSlug) => void;
onPersonaClick?: (slug: PersonaSlug) => void;
cta?: ReactNode;
}
/**
* Single-surface canon of the 13 Waggle bee personas.
*
* @remarks
* Copy is imported verbatim from `_data/personas.ts` — do not override in-place.
* Assets are loaded eagerly via `next/image` (optimizer serves ~256px
* AVIF/WebP of the 2048px source PNGs) with an `onError` fallback that flips
* the tile to a hex-texture placeholder. The placeholder auto-disables when an
* asset loads successfully, so shipping new PNGs requires no code change.
*
* @todo compact variant scaffolding — implement in future sprint
*/
export default function BrandPersonasCard({
eyebrow,
heading,
subtitle,
showFillerTiles = true,
variant = 'landing',
onTileHover,
onPersonaClick,
cta,
}: BrandPersonasCardProps) {
const t = useTranslations('landing.brand_personas');
const resolvedHeading = heading ?? t('default_heading');
const resolvedSubtitle = subtitle ?? t('default_subtitle');
const [erroredSlugs, setErroredSlugs] = useState<ReadonlySet<PersonaSlug>>(
() => new Set(),
);
const handleAssetError = useCallback((slug: PersonaSlug) => {
setErroredSlugs((prev) => {
if (prev.has(slug)) return prev;
const next = new Set(prev);
next.add(slug);
return next;
});
}, []);
if (variant === 'compact') {
return (
<div
data-testid="brand-personas-card-compact"
data-variant="compact"
aria-label={t('compact_aria')}
>
{/* Compact variant scaffolding — intentional stub.
TypeScript interface is stable; parent pages may wire props today
and receive a fuller layout in a future sprint without refactor. */}
</div>
);
}
return (
<section
data-testid="brand-personas-card"
data-variant="landing"
aria-labelledby="waggle-hive-heading"
style={sectionStyle}
>
<header style={headerStyle}>
{eyebrow ? <p style={eyebrowStyle}>{eyebrow}</p> : null}
<h2 id="waggle-hive-heading" style={headingStyle}>
{resolvedHeading}
</h2>
<p style={subtitleStyle}>{resolvedSubtitle}</p>
</header>
<ul
role="list"
data-testid="brand-personas-grid"
className="waggle-persona-grid"
>
{LANDING_GRID_SEQUENCE.map((entry, index) => {
if (entry === 'filler') {
if (!showFillerTiles) return null;
return (
<li
key={`filler-${index}`}
aria-hidden="true"
data-testid="brand-personas-filler"
className="waggle-persona-filler"
/>
);
}
const persona = personaByOrder.get(entry);
if (!persona) {
// Guard: should never happen — sequence mirrors canonical order.
return null;
}
return (
<PersonaTile
key={persona.slug}
persona={persona}
hasError={erroredSlugs.has(persona.slug)}
onAssetError={handleAssetError}
onPersonaClick={onPersonaClick}
onTileHover={onTileHover}
/>
);
})}
</ul>
{cta ? (
<div data-testid="brand-personas-cta" style={ctaWrapperStyle}>
{cta}
</div>
) : null}
<style>{scopedCss}</style>
</section>
);
}
interface PersonaTileProps {
persona: Persona;
hasError: boolean;
onAssetError: (slug: PersonaSlug) => void;
onPersonaClick?: (slug: PersonaSlug) => void;
onTileHover?: (slug: PersonaSlug) => void;
}
function PersonaTile({
persona,
hasError,
onAssetError,
onPersonaClick,
onTileHover,
}: PersonaTileProps) {
const handleClick = useCallback(() => {
onPersonaClick?.(persona.slug);
}, [onPersonaClick, persona.slug]);
const handleKey = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (!onPersonaClick) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onPersonaClick(persona.slug);
}
},
[onPersonaClick, persona.slug],
);
const handleHover = useCallback(() => {
onTileHover?.(persona.slug);
}, [onTileHover, persona.slug]);
const handleFocus = useCallback(() => {
// Keyboard-only "hover" equivalent so a11y consumers get the same signal.
onTileHover?.(persona.slug);
}, [onTileHover, persona.slug]);
const isInteractive = Boolean(onPersonaClick);
const figure = (
<figure className="waggle-persona-figure">
<div className="waggle-persona-asset-frame">
{hasError ? (
<div
data-testid={`persona-placeholder-${persona.slug}`}
data-placeholder="true"
className="waggle-persona-placeholder"
aria-hidden="true"
>
<span className="waggle-persona-placeholder-dot" />
</div>
) : (
/* next/image (optimizer → ~256px AVIF/WebP) makes eager loading
affordable; the raw 2048px PNGs are ~2.5 MB each. Eager so a
tile never paints as an empty frame while lazy IO waits. */
<Image
src={persona.imagePath}
alt={persona.alt}
loading="eager"
width={256}
height={256}
className="waggle-persona-asset"
onError={() => onAssetError(persona.slug)}
/>
)}
</div>
<figcaption className="waggle-persona-caption">
<strong className="waggle-persona-title">{persona.title}</strong>
<span className="waggle-persona-role">{persona.role}</span>
</figcaption>
</figure>
);
const tileStyle = {
'--accent': PERSONA_ACCENTS[persona.slug],
} as CSSProperties;
return (
<li
data-testid={`persona-tile-${persona.slug}`}
data-slug={persona.slug}
data-placeholder={hasError ? 'true' : undefined}
className="waggle-persona-tile"
style={tileStyle}
onMouseEnter={handleHover}
onFocus={handleFocus}
>
{isInteractive ? (
<button
type="button"
className="waggle-persona-button"
aria-label={persona.alt}
onClick={handleClick}
onKeyDown={handleKey}
>
{figure}
</button>
) : (
figure
)}
</li>
);
}
/* ------------------------------------------------------------------ */
/* Inline styles (match existing apps/www convention — no Tailwind) */
/* ------------------------------------------------------------------ */
const sectionStyle: CSSProperties = {
background: 'var(--hive-950, #0e0c07)',
padding: '96px 24px',
width: '100%',
boxSizing: 'border-box',
};
const headerStyle: CSSProperties = {
maxWidth: 1200,
margin: '0 auto 48px',
textAlign: 'center',
};
const eyebrowStyle: CSSProperties = {
fontFamily: "var(--sans)",
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.12em',
color: 'var(--honey-500, #e9a52c)',
margin: 0,
marginBottom: 12,
};
const headingStyle: CSSProperties = {
fontFamily: "var(--sans)",
fontSize: 'clamp(28px, 4vw, 32px)',
fontWeight: 700,
color: 'var(--hive-50, #f6f1e4)',
margin: 0,
marginBottom: 12,
};
const subtitleStyle: CSSProperties = {
fontFamily: "var(--sans)",
fontSize: 'clamp(16px, 2vw, 18px)',
fontWeight: 400,
color: 'var(--hive-300, #c8bfa9)',
margin: 0,
};
const ctaWrapperStyle: CSSProperties = {
maxWidth: 1200,
margin: '48px auto 0',
display: 'flex',
justifyContent: 'center',
};
/* ------------------------------------------------------------------ */
/* Scoped CSS — component-local selectors to avoid global collisions */
/* ------------------------------------------------------------------ */
const scopedCss = `
.waggle-persona-grid {
list-style: none;
padding: 0;
margin: 0 auto;
max-width: 1200px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
@media (max-width: 1023px) {
.waggle-persona-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.waggle-persona-filler { display: none; }
}
@media (max-width: 640px) {
.waggle-persona-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.waggle-persona-tile {
position: relative;
list-style: none;
overflow: hidden;
background: linear-gradient(180deg, #14110b 0%, #0e0c07 100%);
border: 1px solid #1f1a12;
border-radius: 16px;
padding: 20px;
min-height: 260px;
transition: border-color 200ms ease-out, transform 200ms ease-out,
box-shadow 200ms ease-out;
}
/* Per-role accent hairline across the top edge of each tile. */
.waggle-persona-tile::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--accent, #e9a52c);
opacity: 0.5;
transition: opacity 200ms ease-out;
}
.waggle-persona-tile:hover,
.waggle-persona-tile:focus-within {
border-color: var(--accent, #e9a52c);
transform: translateY(-3px);
box-shadow: 0 12px 34px -16px color-mix(in srgb, var(--accent, #e9a52c) 55%, transparent);
}
.waggle-persona-tile:hover::before,
.waggle-persona-tile:focus-within::before {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.waggle-persona-tile,
.waggle-persona-tile:hover,
.waggle-persona-tile:focus-within {
transition: none;
transform: none;
}
}
.waggle-persona-button {
all: unset;
display: block;
width: 100%;
cursor: pointer;
border-radius: 12px;
}
.waggle-persona-button:focus-visible {
outline: 2px solid var(--accent, #e9a52c);
outline-offset: 2px;
}
.waggle-persona-figure {
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.waggle-persona-asset-frame {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
max-width: 256px;
display: flex;
align-items: center;
justify-content: center;
}
.waggle-persona-asset {
position: relative;
width: 100%;
height: 100%;
object-fit: contain;
display: block;
/* The mascot PNGs are background-transparent (2026-07-06 flood-fill) —
they sit directly on the card gradient. The old dark vignette +
edge-fade masks compensated for baked-black squares and are gone:
with real alpha they READ as a dark box behind the art. */
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.35));
}
.waggle-persona-placeholder {
width: 100%;
height: 100%;
background-image: url("${HEX_TEXTURE_PATH}");
background-size: cover;
background-position: center;
background-color: #14110b;
opacity: 0.6;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.waggle-persona-placeholder-dot {
display: block;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f6c45a;
box-shadow: 0 0 24px rgba(246, 196, 90, 0.4);
}
.waggle-persona-caption {
text-align: center;
display: flex;
flex-direction: column;
gap: 6px;
}
.waggle-persona-title {
font-family: var(--sans);
font-size: 16px;
font-weight: 600;
color: var(--accent, #f6c45a);
letter-spacing: 0.01em;
}
.waggle-persona-role {
font-family: var(--sans);
font-size: 13px;
font-weight: 400;
color: #c8bfa9;
line-height: 1.45;
}
/* Filler slots keep the staggered grid rhythm without reading as broken
cards: no border, no card surface — an outlined-but-hollow frame looks
like missing content. Instead: ambient comb texture that fades out
radially, so the corners read as intentional negative space.
Decorative only (aria-hidden on the element). */
.waggle-persona-filler {
position: relative;
list-style: none;
min-height: 260px;
border-radius: 16px;
overflow: hidden;
/* Round-7: complete the falloff — the texture dissolves toward the grid's
outer edge so the ghost reads as intentional ambience, never as an
unloaded card. */
-webkit-mask-image: linear-gradient(to right, rgba(0,0,0,0.8), rgba(0,0,0,0.15));
mask-image: linear-gradient(to right, rgba(0,0,0,0.8), rgba(0,0,0,0.15));
}
.waggle-persona-filler:nth-of-type(1),
li.waggle-persona-filler:first-child {
-webkit-mask-image: linear-gradient(to left, rgba(0,0,0,0.8), rgba(0,0,0,0.15));
mask-image: linear-gradient(to left, rgba(0,0,0,0.8), rgba(0,0,0,0.15));
}
/* Round-6: fainter + slightly shrunken so the ghosts can't be mistaken for
unloaded cards — clearly ambient texture, not content-in-waiting. */
.waggle-persona-filler::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(circle at 50% 46%, rgba(233, 165, 44, 0.10), rgba(233, 165, 44, 0) 62%),
url("${HEX_TEXTURE_PATH}") center / cover no-repeat;
opacity: 0.2;
transform: scale(0.88);
-webkit-mask-image: radial-gradient(circle at 50% 50%, #000 22%, transparent 74%);
mask-image: radial-gradient(circle at 50% 50%, #000 22%, transparent 74%);
}
.waggle-persona-filler::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 34px;
height: 38px;
transform: translate(-50%, -50%);
background: no-repeat center / contain
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='42' height='46' viewBox='0 0 42 46' fill='none'%3E%3Cpath d='M21 2 L39 12.5 V33.5 L21 44 L3 33.5 V12.5 Z' stroke='%23e9a52c' stroke-width='1.4' stroke-linejoin='round'/%3E%3C/svg%3E");
opacity: 0.18;
}
`;

View File

@@ -0,0 +1,71 @@
'use client';
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { detectOSFromUserAgent, type OSId } from '../_lib/os-detection';
import { emit, events } from '../_lib/event-taxonomy';
interface DownloadCTAProps {
readonly variant?: 'primary' | 'ghost';
readonly size?: 'default' | 'small';
readonly section: 'hero' | 'navbar' | 'solo-tier' | 'final-cta';
readonly children?: ReactNode;
readonly style?: CSSProperties;
}
const DOWNLOAD_URL = '/download';
/**
* OS-aware download CTA. Renders a generic "Download" label at SSR + first
* paint, then swaps to "Download for {os}" after hydration via
* `navigator.userAgent` detection.
*
* Styling comes from the shared `.btn` primitives in globals.css so every
* download button on the page is pixel-identical. Strings live in
* `messages/en.json` under `landing.download_cta.*` with an ICU placeholder
* for the OS name.
*/
export default function DownloadCTA({
variant = 'primary',
size = 'default',
section,
children,
style,
}: DownloadCTAProps) {
const t = useTranslations('landing.download_cta');
const [os, setOS] = useState<OSId | null>(null);
useEffect(() => {
if (typeof navigator !== 'undefined') {
setOS(detectOSFromUserAgent(navigator.userAgent));
}
}, []);
const label = children ?? (os ? t('with_os', { os }) : t('default'));
const className = [
'btn',
variant === 'primary' ? 'btn-primary' : 'btn-ghost',
size === 'small' ? 'btn-small' : '',
]
.filter(Boolean)
.join(' ');
const handleClick = () => {
emit({
name: events.ctaClick,
properties: { section, os: os ?? 'unknown' },
});
};
return (
<a
href={DOWNLOAD_URL}
className={className}
onClick={handleClick}
style={style}
>
{label}
</a>
);
}

View File

@@ -0,0 +1,67 @@
.header {
max-width: 720px;
margin: 0 auto 64px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.item {
padding: 28px 26px 30px;
border-radius: var(--r-lg);
background: var(--hive-900);
border: 1px solid var(--line-soft);
transition: border-color 0.2s ease, transform 0.2s ease,
box-shadow 0.2s ease;
}
.item:hover {
border-color: var(--honey-line);
box-shadow: var(--shadow-honey);
transform: translateY(-2px);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 11px;
border: 1px solid var(--honey-line);
background: var(--honey-wash);
color: var(--honey-400);
margin-bottom: 18px;
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--hive-50);
margin-bottom: 10px;
}
.body {
font-size: var(--text-small);
line-height: 1.6;
color: var(--hive-300);
}
@media (max-width: 1023px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,127 @@
import { getTranslations } from 'next-intl/server';
import Reveal from './Reveal';
import styles from './FeatureGrid.module.css';
type FeatureKey =
| 'personas'
| 'models'
| 'harvest'
| 'loops'
| 'skills'
| 'memory_center';
const FEATURES: readonly FeatureKey[] = [
'personas',
'models',
'harvest',
'loops',
'skills',
'memory_center',
];
/**
* Six feature cards, each naming a real subsystem (persona roster, LiteLLM
* routing, Harvest, Loops + approval queue, skills/connectors/MCP catalog,
* Memory Center). Strings under `landing.features.*`.
*/
export default async function FeatureGrid() {
const t = await getTranslations('landing.features');
return (
<section
id="features"
className="section"
aria-labelledby="features-heading"
>
<div className="container-wide">
<header className={styles.header}>
<p className="eyebrow">{t('eyebrow')}</p>
<h2 id="features-heading" className="section-headline">
{t('headline')}
</h2>
</header>
<div className={styles.grid}>
{FEATURES.map((key, i) => (
<Reveal key={key} delay={((i % 3) + 1) as 1 | 2 | 3}>
<div className={styles.item}>
<span className={styles.icon} aria-hidden="true">
<FeatureIcon feature={key} />
</span>
<h3 className={styles.title}>{t(`items.${key}.title`)}</h3>
<p className={styles.body}>{t(`items.${key}.body`)}</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
);
}
/** Minimal 18px line icons — one per subsystem, stroke inherits currentColor. */
function FeatureIcon({ feature }: { readonly feature: FeatureKey }) {
const common = {
width: 18,
height: 18,
viewBox: '0 0 18 18',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
strokeLinecap: 'round',
strokeLinejoin: 'round',
} as const;
switch (feature) {
case 'personas':
return (
<svg {...common} aria-hidden="true">
<circle cx="6" cy="6.5" r="2.6" />
<path d="M1.8 14.8 C2.4 11.8 4.4 10.6 6 10.6 C7.6 10.6 9.6 11.8 10.2 14.8" />
<circle cx="12.8" cy="5.4" r="2" />
<path d="M10.9 9.4 C12 8.8 14.6 9 16.2 12.4" />
</svg>
);
case 'models':
return (
<svg {...common} aria-hidden="true">
<circle cx="9" cy="9" r="2.2" />
<path d="M9 6.8 V2.2 M9 11.2 V15.8 M6.8 9 H2.2 M11.2 9 H15.8" />
<circle cx="9" cy="2.2" r="1.2" />
<circle cx="9" cy="15.8" r="1.2" />
<circle cx="2.2" cy="9" r="1.2" />
<circle cx="15.8" cy="9" r="1.2" />
</svg>
);
case 'harvest':
return (
<svg {...common} aria-hidden="true">
<path d="M9 2 V11 M5.4 7.6 L9 11.2 L12.6 7.6" />
<path d="M2.5 12.5 V14.5 C2.5 15.3 3.2 16 4 16 H14 C14.8 16 15.5 15.3 15.5 14.5 V12.5" />
</svg>
);
case 'loops':
return (
<svg {...common} aria-hidden="true">
<path d="M14.5 7 A6 6 0 1 0 15.5 10.5" />
<path d="M15.8 3.4 L15.8 7.2 L12 7.2" />
</svg>
);
case 'skills':
return (
<svg {...common} aria-hidden="true">
<rect x="2.4" y="2.4" width="5.6" height="5.6" rx="1.4" />
<rect x="10" y="2.4" width="5.6" height="5.6" rx="1.4" />
<rect x="2.4" y="10" width="5.6" height="5.6" rx="1.4" />
<path d="M12.8 10.4 V15.2 M10.4 12.8 H15.2" />
</svg>
);
case 'memory_center':
return (
<svg {...common} aria-hidden="true">
<path d="M9 1.8 L15.2 5.4 L15.2 12.6 L9 16.2 L2.8 12.6 L2.8 5.4 Z" />
<circle cx="9" cy="9" r="2.4" />
</svg>
);
}
}

View File

@@ -0,0 +1,71 @@
.section {
position: relative;
padding: var(--section-pad) var(--gutter);
text-align: center;
overflow: hidden;
border-top: 1px solid var(--line-soft);
}
.glow {
position: absolute;
bottom: -300px;
left: 50%;
transform: translateX(-50%);
width: 900px;
height: 520px;
background: radial-gradient(
ellipse at center,
rgba(233, 165, 44, 0.1) 0%,
rgba(233, 165, 44, 0.03) 45%,
transparent 70%
);
pointer-events: none;
}
.inner {
position: relative;
max-width: 640px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
}
.headline {
font-size: clamp(2.125rem, 4.5vw, 3.25rem);
font-weight: 800;
letter-spacing: -0.03em;
color: var(--hive-50);
margin-bottom: 18px;
}
.subhead {
font-size: var(--text-lead);
line-height: 1.6;
color: var(--hive-300);
margin-bottom: 36px;
}
.ctaRow {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
margin-bottom: 40px;
}
.kvark {
font-size: 13px;
color: var(--text-muted);
}
.kvarkLink {
color: var(--honey-400);
text-decoration: none;
font-weight: 600;
margin-left: 6px;
}
.kvarkLink:hover {
color: var(--honey-300);
}

View File

@@ -0,0 +1,51 @@
import { getTranslations } from 'next-intl/server';
import DownloadCTA from './DownloadCTA';
import styles from './FinalCTA.module.css';
const WAGGLE_REPO_URL = 'https://github.com/marolinik/waggle-os';
const KVARK_CONTACT =
'mailto:kvark@egzakta.com?subject=Waggle%20%E2%86%92%20KVARK%20sovereign%20deployment';
/**
* Closing CTA — echoes the hero promise, then routes to Download or GitHub,
* with the KVARK sovereign-deployment escape hatch underneath. Strings
* under `landing.final_cta.*`.
*/
export default async function FinalCTA() {
const t = await getTranslations('landing.final_cta');
return (
<section
id="final-cta"
className={`${styles.section} honeycomb-bg`}
aria-labelledby="final-heading"
>
<div className={styles.glow} aria-hidden="true" />
<div className={styles.inner}>
<h2 id="final-heading" className={styles.headline}>
{t('headline')}
</h2>
<p className={styles.subhead}>{t('subhead')}</p>
<div className={styles.ctaRow}>
<DownloadCTA section="final-cta" variant="primary" />
<a
href={WAGGLE_REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost"
>
{t('cta_github')}
</a>
</div>
<p className={styles.kvark}>
{t('kvark_text')}
<a href={KVARK_CONTACT} className={styles.kvarkLink}>
{t('kvark_cta')}
</a>
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,95 @@
.footer {
padding: 72px var(--gutter) 32px;
background: var(--hive-950);
border-top: 1px solid var(--line-soft);
}
.grid {
max-width: var(--container-wide);
margin: 0 auto 56px;
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
gap: 48px;
}
.brandBlock {
max-width: 320px;
}
.brandDescription {
font-size: 13px;
line-height: 1.6;
color: var(--hive-300);
margin-top: 14px;
margin-bottom: 12px;
}
.attribution {
font-size: 12px;
color: var(--text-muted);
}
.columnTitle {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--hive-200);
margin-bottom: 16px;
}
.columnList {
list-style: none;
padding: 0;
margin: 0;
}
.columnList li {
margin-bottom: 10px;
}
.columnLink {
font-size: 13px;
color: var(--hive-300);
text-decoration: none;
transition: color 0.15s ease;
}
.columnLink:hover {
color: var(--hive-50);
}
.baseline {
max-width: var(--container-wide);
margin: 0 auto;
padding-top: 24px;
border-top: 1px solid var(--hive-800);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: var(--text-muted);
}
.baselineRight {
font-family: var(--mono);
}
@media (max-width: 1023px) {
.grid {
grid-template-columns: 1fr 1fr 1fr;
gap: 32px;
}
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr 1fr;
}
.baseline {
justify-content: flex-start;
flex-direction: column;
}
}

View File

@@ -0,0 +1,105 @@
import { getTranslations } from 'next-intl/server';
import BrandMark from './BrandMark';
import styles from './Footer.module.css';
interface FooterLink {
readonly key: string;
readonly href: string;
readonly external?: boolean;
}
/**
* Footer link map. Rule: every link must resolve to a real destination —
* no `#` placeholders. Columns whose content does not exist yet (blog,
* press, changelog) are omitted until they do.
*/
const PRODUCT_LINKS: readonly FooterLink[] = [
{
key: 'download',
href: '/download',
},
{ key: 'pricing', href: '/#pricing' },
{ key: 'how_it_works', href: '/#how-it-works' },
{ key: 'memory', href: '/#memory' },
];
const RESEARCH_LINKS: readonly FooterLink[] = [
{ key: 'methodology', href: '/docs/methodology' },
{
key: 'benchmarks',
href: 'https://github.com/marolinik/hive-mind',
external: true,
},
{
key: 'hive_mind',
href: 'https://github.com/marolinik/hive-mind',
external: true,
},
];
const COMPANY_LINKS: readonly FooterLink[] = [
{ key: 'about_egzakta', href: 'https://egzakta.com', external: true },
{ key: 'kvark', href: 'https://www.kvark.ai', external: true },
{ key: 'contact', href: 'mailto:hello@egzakta.com' },
];
const LEGAL_LINKS: readonly FooterLink[] = [
{ key: 'terms', href: '/terms' },
{ key: 'privacy', href: '/privacy' },
{ key: 'cookies', href: '/cookies' },
{ key: 'eu_ai_act', href: '/eu-ai-act' },
{
key: 'apache',
href: 'https://github.com/marolinik/hive-mind/blob/master/LICENSE',
external: true,
},
];
const COLUMN_DEFS = [
{ ns: 'product', links: PRODUCT_LINKS },
{ ns: 'research', links: RESEARCH_LINKS },
{ ns: 'company', links: COMPANY_LINKS },
{ ns: 'legal', links: LEGAL_LINKS },
] as const;
export default async function Footer() {
const t = await getTranslations('landing.footer');
return (
<footer id="footer" className={styles.footer}>
<div className={styles.grid}>
<div className={styles.brandBlock}>
<BrandMark withWordmark />
<p className={styles.brandDescription}>{t('brand.description')}</p>
<p className={styles.attribution}>{t('brand.attribution')}</p>
</div>
{COLUMN_DEFS.map((col) => (
<div key={col.ns}>
<h3 className={styles.columnTitle}>{t(`columns.${col.ns}.title`)}</h3>
<ul className={styles.columnList}>
{col.links.map((l) => (
<li key={l.key}>
<a
href={l.href}
{...(l.external
? { target: '_blank', rel: 'noopener noreferrer' }
: null)}
className={styles.columnLink}
>
{t(`columns.${col.ns}.links.${l.key}`)}
</a>
</li>
))}
</ul>
</div>
))}
</div>
<div className={styles.baseline}>
<span>{t('base_line.left')}</span>
<span className={styles.baselineRight}>{t('base_line.right')}</span>
</div>
</footer>
);
}

View File

@@ -0,0 +1,160 @@
.section {
position: relative;
padding: 168px var(--gutter) 104px;
overflow: hidden;
}
.glow {
position: absolute;
top: -240px;
left: 50%;
transform: translateX(-50%);
width: 900px;
height: 560px;
background: radial-gradient(
ellipse at center,
rgba(233, 165, 44, 0.09) 0%,
rgba(233, 165, 44, 0.03) 45%,
transparent 70%
);
pointer-events: none;
}
.grid {
position: relative;
max-width: var(--container-wide);
margin: 0 auto;
display: grid;
grid-template-columns: minmax(0, 10fr) minmax(0, 9fr);
gap: 64px;
align-items: center;
}
.copy {
max-width: 560px;
}
.headline {
font-size: var(--text-display);
font-weight: 800;
line-height: 1.05;
color: var(--hive-50);
margin-bottom: 24px;
}
.headlineEmphasis {
color: var(--honey-400);
}
.subhead {
font-size: var(--text-lead);
line-height: 1.6;
color: var(--hive-300);
margin-bottom: 36px;
max-width: 34em;
}
.ctaRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
margin-bottom: 28px;
}
.microcopy {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 18px;
row-gap: 8px;
font-family: var(--mono);
font-size: var(--text-xs);
color: var(--text-muted);
letter-spacing: 0.02em;
}
.microItem {
display: inline-flex;
align-items: center;
gap: 8px;
}
.microDot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--honey-500);
display: inline-block;
}
.visual {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
}
@media (max-width: 1023px) {
.section {
padding-top: 136px;
padding-bottom: 80px;
}
.grid {
grid-template-columns: 1fr;
gap: 56px;
}
.copy {
max-width: 640px;
}
}
/* Round-7: one benchmark strip in the hero's dead bottom quarter.
Wave R Lane E: the 86.49% number is lifted to a flagship stat chip
(larger mono, honey-wash lozenge) so the leaderboard claim carries weight
near the CTAs; the rest stays quiet supporting mono copy. */
.proofStrip {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-top: 28px;
max-width: 40em;
color: var(--honey-500);
text-decoration: none;
}
.proofStat {
flex-shrink: 0;
display: inline-flex;
align-items: baseline;
padding: 6px 12px;
border-radius: 10px;
background: var(--honey-wash);
border: 1px solid var(--honey-line);
font-family: var(--mono);
font-size: 1.5rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.02em;
color: var(--honey-400);
transition: border-color 0.15s ease, background 0.15s ease;
}
.proofLabel {
font-family: var(--mono);
font-size: var(--text-xs);
letter-spacing: 0.02em;
line-height: 1.5;
color: var(--text-muted);
}
.proofStrip:hover .proofStat {
border-color: var(--honey-500);
background: rgba(233, 165, 44, 0.16);
}
.proofStrip:hover .proofLabel {
color: var(--hive-300);
}

View File

@@ -0,0 +1,71 @@
import { getTranslations } from 'next-intl/server';
import DownloadCTA from './DownloadCTA';
import HeroVisual from './HeroVisual';
import styles from './Hero.module.css';
const MICROCOPY_KEYS = [
'microcopy_free',
'microcopy_platforms',
'microcopy_oss',
] as const;
/**
* Hero — single committed headline (the A/B variant infra was removed with
* the 2026-07 rebuild). Two columns: positioning copy left, the memory-core
* window visual right. All strings under `landing.hero.*`.
*/
export default async function Hero() {
const t = await getTranslations('landing.hero');
return (
<section id="hero" className={`${styles.section} honeycomb-bg`}>
<div className={styles.glow} aria-hidden="true" />
<div className={styles.grid}>
<div className={styles.copy}>
<p className="eyebrow">{t('eyebrow')}</p>
<h1 className={styles.headline}>
{t('headline_lead')}{' '}
<span className={styles.headlineEmphasis}>
{t('headline_emphasis')}
</span>
.
</h1>
<p className={styles.subhead}>{t('subhead')}</p>
<div className={styles.ctaRow}>
<DownloadCTA section="hero" variant="primary" />
<a href="#proof" className="btn btn-ghost">
{t('cta_secondary')}
</a>
</div>
<ul className={styles.microcopy}>
{MICROCOPY_KEYS.map((key) => (
<li key={key} className={styles.microItem}>
<span aria-hidden="true" className={styles.microDot} />
{t(key)}
</li>
))}
</ul>
{/* Round-7: the hero's dead bottom quarter carries the product's
strongest proof — one benchmark strip, linking to #proof.
Wave R Lane E: the 86.49% number is lifted to a flagship stat
(larger mono, honey) so the leaderboard claim carries visual
weight near the CTAs; the rest stays quiet supporting copy. */}
<a href="#proof" className={styles.proofStrip}>
<span className={styles.proofStat}>{t('proof_stat')}</span>
<span className={styles.proofLabel}>{t('proof_strip')}</span>
<span aria-hidden="true"></span>
</a>
</div>
<div className={styles.visual}>
<HeroVisual />
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,137 @@
.window {
width: 100%;
max-width: 560px;
border-radius: var(--r-lg);
border: 1px solid var(--line);
background: linear-gradient(180deg, var(--hive-900) 0%, var(--hive-950) 100%);
box-shadow: var(--shadow-elevated);
overflow: hidden;
}
.titleBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--line-soft);
background: rgba(31, 26, 18, 0.6);
}
.trafficDots {
display: inline-flex;
gap: 6px;
}
.trafficDot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--hive-600);
opacity: 0.7;
}
.titleText {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
flex: 1;
text-align: center;
}
.titleBadge {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.04em;
color: var(--honey-400);
border: 1px solid var(--honey-line);
background: var(--honey-wash);
border-radius: 999px;
padding: 2px 9px;
white-space: nowrap;
}
.svg {
display: block;
width: 100%;
height: auto;
}
.footerBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 9px 14px;
border-top: 1px solid var(--line-soft);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.03em;
color: var(--text-muted);
}
/* Edge pulse: honey energy flowing between memory and models. */
.edge {
stroke: var(--line-strong);
stroke-width: 1.2;
fill: none;
}
.edgePulse {
stroke: var(--honey-500);
stroke-width: 1.4;
fill: none;
stroke-dasharray: 10 110;
stroke-linecap: round;
opacity: 0.8;
animation: edge-flow 3.2s linear infinite;
}
.edgePulse2 {
animation-delay: 0.8s;
}
.edgePulse3 {
animation-delay: 1.6s;
}
.edgePulse4 {
animation-delay: 2.4s;
}
@keyframes edge-flow {
from {
stroke-dashoffset: 120;
}
to {
stroke-dashoffset: 0;
}
}
.coreGlow {
animation: core-breathe 4s ease-in-out infinite;
transform-origin: center;
transform-box: fill-box;
}
@keyframes core-breathe {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.edgePulse {
animation: none;
stroke-dasharray: none;
opacity: 0.35;
}
.coreGlow {
animation: none;
}
}

View File

@@ -0,0 +1,160 @@
import { getTranslations } from 'next-intl/server';
import styles from './HeroVisual.module.css';
const CHIPS = [
{ key: 'claude', x: 28, y: 36, flow: 'out' },
{ key: 'gpt', x: 374, y: 36, flow: 'out' },
{ key: 'qwen', x: 28, y: 258, flow: 'in' },
{ key: 'gemini', x: 374, y: 258, flow: 'out' },
] as const;
const CHIP_W = 118;
const CHIP_H = 44;
/**
* Hero visualization — a desktop-app window (Waggle ships as a Tauri
* binary) framing the true architecture: one local memory core serving
* four model chips. Edges pulse honey; the `qwen · local` edge flows
* INTO the core (commit) while the others flow out (recall).
*
* Server component: the animation is pure CSS (HeroVisual.module.css),
* disabled under `prefers-reduced-motion`. No invented numbers anywhere —
* labels name real subsystems only.
*/
export default async function HeroVisual() {
const t = await getTranslations('landing.hero_visual');
return (
<figure className={styles.window} aria-label={t('aria_label')}>
<div className={styles.titleBar}>
<span className={styles.trafficDots} aria-hidden="true">
<span className={styles.trafficDot} />
<span className={styles.trafficDot} />
<span className={styles.trafficDot} />
</span>
<span className={styles.titleText}>{t('window_title')}</span>
<span className={styles.titleBadge}>{t('window_badge')}</span>
</div>
<svg
className={styles.svg}
viewBox="0 0 520 340"
role="img"
aria-hidden="true"
focusable="false"
>
<defs>
<radialGradient id="hv-core-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="rgba(233,165,44,0.22)" />
<stop offset="70%" stopColor="rgba(233,165,44,0.05)" />
<stop offset="100%" stopColor="rgba(233,165,44,0)" />
</radialGradient>
</defs>
{/* Edges: base line + honey pulse. Recall edges run core → chip;
the commit edge (qwen · local) runs chip → core. */}
<path className={styles.edge} d="M 232,140 C 200,112 182,84 150,62" />
<path
className={`${styles.edgePulse}`}
d="M 232,140 C 200,112 182,84 150,62"
/>
<path className={styles.edge} d="M 288,140 C 320,112 338,84 370,62" />
<path
className={`${styles.edgePulse} ${styles.edgePulse2}`}
d="M 288,140 C 320,112 338,84 370,62"
/>
<path className={styles.edge} d="M 150,278 C 182,256 200,224 232,196" />
<path
className={`${styles.edgePulse} ${styles.edgePulse3}`}
d="M 150,278 C 182,256 200,224 232,196"
/>
<path className={styles.edge} d="M 288,196 C 320,224 338,256 370,278" />
<path
className={`${styles.edgePulse} ${styles.edgePulse4}`}
d="M 288,196 C 320,224 338,256 370,278"
/>
{/* Memory core */}
<circle
className={styles.coreGlow}
cx="260"
cy="168"
r="78"
fill="url(#hv-core-glow)"
/>
<path
d="M260 112 L308.5 140 L308.5 196 L260 224 L211.5 196 L211.5 140 Z"
fill="rgba(31, 26, 18, 0.85)"
stroke="var(--honey-500)"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<text
x="260"
y="165"
textAnchor="middle"
fill="var(--hive-50)"
fontSize="13.5"
fontWeight="600"
fontFamily="var(--sans)"
>
{t('center_label')}
</text>
<text
x="260"
y="184"
textAnchor="middle"
fill="var(--text-muted)"
fontSize="9"
fontFamily="var(--mono)"
>
{t('center_sublabel')}
</text>
{/* Model chips */}
{CHIPS.map((chip) => (
<g key={chip.key}>
<rect
x={chip.x}
y={chip.y}
width={CHIP_W}
height={CHIP_H}
rx="10"
fill="var(--surface)"
stroke="var(--line-strong)"
strokeWidth="1"
/>
<text
x={chip.x + 14}
y={chip.y + 19}
fill="var(--hive-100)"
fontSize="12"
fontWeight="600"
fontFamily="var(--mono)"
>
{t(`chips.${chip.key}_primary`)}
</text>
<text
x={chip.x + 14}
y={chip.y + 33}
fill={chip.flow === 'in' ? 'var(--honey-400)' : 'var(--text-muted)'}
fontSize="9"
fontFamily="var(--mono)"
>
{chip.flow === 'in' ? '↑ ' : '↓ '}
{t(`chips.${chip.key}_sub`)}
</text>
</g>
))}
</svg>
<div className={styles.footerBar}>
<span>{t('footer_left')}</span>
<span>{t('footer_right')}</span>
</div>
</figure>
);
}

View File

@@ -0,0 +1,93 @@
.header {
max-width: 720px;
margin: 0 auto 52px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
position: relative;
}
.step {
position: relative;
padding: 30px 28px 32px;
border-radius: var(--r-lg);
background: linear-gradient(180deg, var(--hive-900) 0%, var(--hive-950) 100%);
border: 1px solid var(--line-soft);
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.step:hover {
border-color: var(--honey-line);
transform: translateY(-3px);
box-shadow: var(--shadow-honey);
}
/* Flow connector: a honey chevron seated in the gap before every step after
the first, so the three cards read as one sequence (01 → 02 → 03).
Each step is wrapped in a <Reveal> div, so the chevron hangs off the reveal
wrapper (the actual grid cell), not the inner .step card. */
.steps > :global(.reveal) {
position: relative;
}
.steps > :global(.reveal) + :global(.reveal)::before {
content: '';
position: absolute;
top: 51px;
left: -22px;
width: 20px;
height: 20px;
background: no-repeat center / contain
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M7 4l6 6-6 6' stroke='%23e9a52c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
opacity: 0.75;
z-index: 2;
}
.stepNumber {
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
color: var(--honey-500);
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 12px;
border: 1px solid var(--honey-line);
background: var(--honey-wash);
box-shadow: 0 0 20px rgba(233, 165, 44, 0.12);
margin-bottom: 22px;
}
.stepTitle {
font-size: var(--text-h3);
font-weight: 600;
color: var(--hive-50);
margin-bottom: 12px;
}
.stepBody {
font-size: var(--text-small);
line-height: 1.65;
color: var(--hive-300);
}
@media (max-width: 860px) {
.steps {
grid-template-columns: 1fr;
gap: 20px;
}
/* Chevrons point downward when the flow stacks vertically. */
.steps > :global(.reveal) + :global(.reveal)::before {
top: -21px;
left: 30px;
transform: rotate(90deg);
}
}

View File

@@ -0,0 +1,44 @@
import { getTranslations } from 'next-intl/server';
import Reveal from './Reveal';
import styles from './HowItWorks.module.css';
const STEPS = ['step_01', 'step_02', 'step_03'] as const;
/**
* Three-step product story: import history → work in workspaces → memory
* compounds. Strings under `landing.how_it_works.*`.
*/
export default async function HowItWorks() {
const t = await getTranslations('landing.how_it_works');
return (
<section
id="how-it-works"
className="section"
aria-labelledby="how-heading"
>
<div className="container">
<header className={styles.header}>
<p className="eyebrow">{t('eyebrow')}</p>
<h2 id="how-heading" className="section-headline">
{t('headline')}
</h2>
</header>
<div className={styles.steps}>
{STEPS.map((step, i) => (
<Reveal key={step} delay={(i + 1) as 1 | 2 | 3}>
<div className={styles.step}>
<span className={styles.stepNumber} aria-hidden="true">
{t(`${step}.number`)}
</span>
<h3 className={styles.stepTitle}>{t(`${step}.title`)}</h3>
<p className={styles.stepBody}>{t(`${step}.body`)}</p>
</div>
</Reveal>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,89 @@
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 72px;
align-items: center;
}
.body {
font-size: var(--text-body);
line-height: 1.7;
color: var(--hive-300);
margin-top: 20px;
max-width: 34em;
}
.chips {
list-style: none;
padding: 0;
margin: 28px 0 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.02em;
color: var(--hive-200);
border: 1px solid var(--line-strong);
background: var(--surface);
border-radius: 999px;
padding: 7px 14px;
}
/* ── Pipeline card ── */
.pipeline {
border-radius: var(--r-lg);
border: 1px solid var(--line);
background: linear-gradient(180deg, var(--hive-900) 0%, var(--hive-950) 100%);
box-shadow: var(--shadow-elevated);
padding: 32px 36px;
display: flex;
flex-direction: column;
align-items: stretch;
}
.node {
border-radius: var(--r);
border: 1px solid var(--line-strong);
background: var(--surface);
padding: 14px 18px;
text-align: center;
}
.nodeAccent {
border-color: var(--honey-line);
background: var(--honey-wash);
}
.nodeTitle {
font-size: 14px;
font-weight: 600;
color: var(--hive-50);
display: block;
}
.nodeSub {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.03em;
color: var(--text-muted);
display: block;
margin-top: 4px;
}
.connector {
display: flex;
justify-content: center;
padding: 6px 0;
color: var(--honey-600);
}
@media (max-width: 1023px) {
.grid {
grid-template-columns: minmax(0, 1fr);
gap: 48px;
}
}

View File

@@ -0,0 +1,80 @@
import { getTranslations } from 'next-intl/server';
import Reveal from './Reveal';
import styles from './MemoryDiagram.module.css';
const CHIP_KEYS = ['local', 'provenance', 'erasure'] as const;
/**
* "Under the hood" — the real memory pipeline, named after the actual
* subsystems (frames → hybrid search → knowledge graph → any model).
* Copy under `landing.memory.*`; the diagram is semantic HTML so it
* stacks naturally and needs no JS.
*/
export default async function MemoryDiagram() {
const t = await getTranslations('landing.memory');
return (
<section id="memory" className="section" aria-labelledby="memory-heading">
<div className="container">
<div className={styles.grid}>
<div>
<p className="eyebrow">{t('eyebrow')}</p>
<h2 id="memory-heading" className="section-headline">
{t('headline')}
</h2>
<p className={styles.body}>{t('body')}</p>
<ul className={styles.chips}>
{CHIP_KEYS.map((key) => (
<li key={key} className={styles.chip}>
{t(`chips.${key}`)}
</li>
))}
</ul>
</div>
<Reveal>
<div className={styles.pipeline} role="img" aria-label={t('headline')}>
<div className={styles.node}>
<span className={styles.nodeTitle}>{t('diagram.input')}</span>
</div>
<Arrow />
<div className={styles.node}>
<span className={styles.nodeTitle}>{t('diagram.frames')}</span>
</div>
<Arrow />
<div className={styles.node}>
<span className={styles.nodeTitle}>{t('diagram.search')}</span>
<span className={styles.nodeSub}>{t('diagram.search_sub')}</span>
</div>
<Arrow />
<div className={styles.node}>
<span className={styles.nodeTitle}>{t('diagram.graph')}</span>
<span className={styles.nodeSub}>{t('diagram.graph_sub')}</span>
</div>
<Arrow />
<div className={`${styles.node} ${styles.nodeAccent}`}>
<span className={styles.nodeTitle}>{t('diagram.output')}</span>
</div>
</div>
</Reveal>
</div>
</div>
</section>
);
}
function Arrow() {
return (
<span className={styles.connector} aria-hidden="true">
<svg width="12" height="14" viewBox="0 0 12 14" fill="none">
<path
d="M6 0 V11 M2 8 L6 12 L10 8"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
);
}

View File

@@ -0,0 +1,145 @@
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
transition: background 0.25s ease, border-color 0.25s ease,
backdrop-filter 0.25s ease;
border-bottom: 1px solid transparent;
background: transparent;
}
.headerScrolled {
background: rgba(14, 12, 7, 0.82);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom-color: var(--line-soft);
}
.inner {
max-width: var(--container-wide);
margin: 0 auto;
padding: 0 var(--gutter);
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
.brand {
display: inline-flex;
align-items: center;
text-decoration: none;
flex-shrink: 0;
}
.nav {
display: flex;
align-items: center;
gap: 4px;
}
.navLink {
padding: 8px 14px;
border-radius: var(--r-sm);
font-size: var(--text-small);
font-weight: 500;
color: var(--hive-300);
text-decoration: none;
transition: color 0.15s ease, background 0.15s ease;
}
.navLink:hover {
color: var(--hive-50);
background: rgba(236, 227, 208, 0.05);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.signIn {
padding: 8px 14px;
border-radius: var(--r-sm);
font-size: var(--text-small);
font-weight: 500;
color: var(--hive-200);
text-decoration: none;
transition: color 0.15s ease;
background: none;
border: none;
cursor: pointer;
font-family: var(--sans);
}
.signIn:hover {
color: var(--hive-50);
}
.menuButton {
display: none;
background: none;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
color: var(--hive-100);
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
cursor: pointer;
}
.mobilePanel {
display: none;
}
@media (max-width: 860px) {
.nav {
display: none;
}
.signIn {
display: none;
}
.menuButton {
display: inline-flex;
}
.mobilePanel {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px var(--gutter) 20px;
background: rgba(14, 12, 7, 0.96);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--line-soft);
}
.mobileLink {
padding: 12px 14px;
border-radius: var(--r-sm);
font-size: 15px;
font-weight: 500;
color: var(--hive-200);
text-decoration: none;
}
.mobileLink:hover {
color: var(--hive-50);
background: rgba(236, 227, 208, 0.05);
}
.mobileActions {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px 0;
}
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { SignInButton, UserButton, Show } from '@clerk/nextjs';
import BrandMark from './BrandMark';
import DownloadCTA from './DownloadCTA';
import styles from './Navbar.module.css';
/* Absolute-path anchors so the navbar also works from /privacy, /terms,
and the other legal pages that render this chrome. */
const NAV_ITEMS = [
{ href: '/#how-it-works', key: 'how_it_works' },
{ href: '/#memory', key: 'memory' },
{ href: '/#proof', key: 'benchmark' },
{ href: '/#open-source', key: 'open_source' },
{ href: '/#pricing', key: 'pricing' },
] as const;
/**
* Fixed top navigation. Transparent over the hero, gains a blurred backdrop
* + hairline border after a small scroll. Collapses to a menu button below
* 860px; the mobile panel reuses the same anchor list.
*
* Stays a Client Component for scroll-aware backdrop + menu state. All
* strings under `landing.navbar.*`.
*/
export default function Navbar() {
const t = useTranslations('landing.navbar');
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
const headerClass = [
styles.header,
scrolled || open ? styles.headerScrolled : '',
]
.filter(Boolean)
.join(' ');
return (
<header className={headerClass}>
<div className={styles.inner}>
<a href="/" className={styles.brand} aria-label={t('aria.home')}>
<BrandMark withWordmark />
</a>
<nav className={styles.nav} aria-label={t('aria.primary')}>
{NAV_ITEMS.map((item) => (
<a key={item.key} href={item.href} className={styles.navLink}>
{t(`links.${item.key}`)}
</a>
))}
</nav>
<div className={styles.actions}>
<Show when="signed-out">
<SignInButton mode="modal">
<button type="button" className={styles.signIn}>
{t('ctas.sign_in')}
</button>
</SignInButton>
</Show>
<Show when="signed-in">
<UserButton />
</Show>
<DownloadCTA section="navbar" size="small">
{t('ctas.download')}
</DownloadCTA>
<button
type="button"
className={styles.menuButton}
aria-expanded={open}
aria-controls="mobile-nav"
aria-label={t('aria.toggle_menu')}
onClick={() => setOpen((v) => !v)}
>
<MenuIcon open={open} />
</button>
</div>
</div>
{open ? (
<nav
id="mobile-nav"
className={styles.mobilePanel}
aria-label={t('aria.primary')}
>
{NAV_ITEMS.map((item) => (
<a
key={item.key}
href={item.href}
className={styles.mobileLink}
onClick={() => setOpen(false)}
>
{t(`links.${item.key}`)}
</a>
))}
<div className={styles.mobileActions}>
<Show when="signed-out">
<SignInButton mode="modal">
<button
type="button"
className={styles.signIn}
style={{ display: 'inline-flex' }}
>
{t('ctas.sign_in')}
</button>
</SignInButton>
</Show>
<Show when="signed-in">
<UserButton />
</Show>
</div>
</nav>
) : null}
</header>
);
}
function MenuIcon({ open }: { readonly open: boolean }) {
return (
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
aria-hidden="true"
>
{open ? (
<path
d="M4 4 L14 14 M14 4 L4 14"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
) : (
<path
d="M2 5 H16 M2 9 H16 M2 13 H16"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
)}
</svg>
);
}

View File

@@ -0,0 +1,87 @@
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 72px;
align-items: center;
}
.body {
font-size: var(--text-body);
line-height: 1.7;
color: var(--hive-300);
margin-top: 20px;
max-width: 34em;
}
.ctaRow {
display: flex;
flex-wrap: wrap;
gap: 14px;
margin-top: 32px;
}
.terminal {
border-radius: var(--r-lg);
border: 1px solid var(--line);
background: var(--hive-950);
box-shadow: var(--shadow-elevated);
overflow: hidden;
}
.terminalBar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--line-soft);
background: rgba(31, 26, 18, 0.6);
}
.terminalDots {
display: inline-flex;
gap: 6px;
}
.terminalDot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--hive-600);
opacity: 0.7;
}
.terminalTitle {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
}
.terminalBody {
margin: 0;
padding: 22px 22px 26px;
font-family: var(--mono);
font-size: 12.5px;
line-height: 1.9;
color: var(--hive-200);
overflow-x: auto;
}
.prompt {
color: var(--honey-500);
user-select: none;
}
.comment {
color: var(--text-muted);
}
.output {
color: var(--healthy);
}
@media (max-width: 1023px) {
.grid {
grid-template-columns: minmax(0, 1fr);
gap: 48px;
}
}

View File

@@ -0,0 +1,88 @@
import { getTranslations } from 'next-intl/server';
import Reveal from './Reveal';
import styles from './OpenSource.module.css';
const HIVE_MIND_URL = 'https://github.com/marolinik/hive-mind';
const NPM_URL = 'https://www.npmjs.com/package/@hive-mind/core';
/**
* Open-source section: hive-mind (the memory substrate) with a terminal
* showing the offline benchmark reproduction path. Command + output are
* verbatim from the OSS repo (benchmarks/locomo/artifacts/w4-n1540/
* recount.mjs — verified against the local clone 2026-07-03); if that
* script moves, update this terminal. Strings under `landing.open_source.*`.
*/
export default async function OpenSource() {
const t = await getTranslations('landing.open_source');
return (
<section
id="open-source"
className="section"
aria-labelledby="oss-heading"
>
<div className="container">
<div className={styles.grid}>
<div>
<p className="eyebrow">{t('eyebrow')}</p>
<h2 id="oss-heading" className="section-headline">
{t('headline')}
</h2>
<p className={styles.body}>{t('body')}</p>
<div className={styles.ctaRow}>
<a
href={HIVE_MIND_URL}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
{t('cta_github')}
</a>
<a
href={NPM_URL}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost"
>
{t('cta_npm')}
</a>
</div>
</div>
<Reveal>
<div className={styles.terminal} aria-label={t('terminal_aria')}>
<div className={styles.terminalBar}>
<span className={styles.terminalDots} aria-hidden="true">
<span className={styles.terminalDot} />
<span className={styles.terminalDot} />
<span className={styles.terminalDot} />
</span>
<span className={styles.terminalTitle}>
{t('terminal_title')}
</span>
</div>
<pre className={styles.terminalBody}>
<code>
<span className={styles.prompt}>$ </span>
git clone https://github.com/marolinik/hive-mind{'\n'}
<span className={styles.prompt}>$ </span>
cd hive-mind/benchmarks/locomo{'\n'}
<span className={styles.comment}>
# recount the committed judgments no API keys, no network
</span>
{'\n'}
<span className={styles.prompt}>$ </span>
node artifacts/w4-n1540/recount.mjs{'\n'}
<span className={styles.output}>
overall 1332/1540 = 86.49%{'\n'}
RECOUNT OK committed judgments reproduce 86.49%.
</span>
</code>
</pre>
</div>
</Reveal>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,190 @@
.header {
text-align: center;
max-width: 640px;
margin: 0 auto 40px;
display: flex;
flex-direction: column;
align-items: center;
}
.toggleRow {
display: flex;
justify-content: center;
gap: 4px;
padding: 4px;
background: var(--hive-900);
border: 1px solid var(--line-soft);
border-radius: 999px;
width: fit-content;
margin: 0 auto 56px;
}
.toggle {
font-size: 13px;
font-weight: 500;
padding: 8px 18px;
border-radius: 999px;
border: none;
cursor: pointer;
font-family: var(--sans);
background: transparent;
color: var(--hive-300);
transition: background 0.15s ease, color 0.15s ease;
}
.toggleActive {
background: var(--surface-2);
color: var(--hive-50);
}
.savePill {
color: var(--honey-400);
font-weight: 600;
margin-left: 6px;
}
.checkoutNotice {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
flex-wrap: wrap;
max-width: 680px;
margin: -28px auto 36px;
padding: 12px 16px;
border: 1px solid var(--honey-line);
border-radius: var(--r-md);
background: color-mix(in srgb, var(--honey-500) 10%, transparent);
color: var(--hive-100);
font-size: 13px;
line-height: 1.5;
text-align: center;
}
.noticeLink {
color: var(--honey-300);
font-weight: 700;
text-decoration: none;
}
.noticeLink:hover {
text-decoration: underline;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
max-width: 1080px;
margin: 0 auto 40px;
align-items: stretch;
}
.tierCard {
position: relative;
border-radius: var(--r-lg);
padding: 30px 28px 28px;
background: var(--hive-900);
border: 1px solid var(--line-soft);
display: flex;
flex-direction: column;
}
.tierCardHighlighted {
background: var(--hive-850);
border-color: var(--honey-line);
box-shadow: var(--shadow-honey);
}
/* Enterprise/KVARK slot: present but visually subordinate to Solo/Team. */
.tierCardQuiet {
background: transparent;
}
.enterpriseBody {
font-size: 13px;
line-height: 1.6;
color: var(--hive-300);
margin: 0 0 26px;
flex-grow: 1;
}
.badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 5px 12px;
border-radius: 999px;
background: var(--honey-500);
color: var(--hive-950);
white-space: nowrap;
}
.tierName {
font-size: 19px;
font-weight: 700;
margin-bottom: 6px;
color: var(--hive-50);
}
.tierTagline {
font-size: 13px;
color: var(--hive-300);
margin-bottom: 20px;
line-height: 1.5;
}
.price {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--hive-50);
}
.priceNote {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
margin-top: 4px;
margin-bottom: 22px;
min-height: 1em;
}
.bullets {
list-style: none;
padding: 0;
margin: 0 0 26px;
flex-grow: 1;
}
.bullet {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 13px;
color: var(--hive-200);
margin-bottom: 11px;
line-height: 1.5;
}
.bulletIcon {
flex-shrink: 0;
margin-top: 3px;
color: var(--honey-500);
}
.tierCta {
width: 100%;
}
@media (max-width: 1023px) {
.grid {
grid-template-columns: 1fr;
max-width: 460px;
}
}

View File

@@ -0,0 +1,253 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import DownloadCTA from './DownloadCTA';
import { emit, events } from '../_lib/event-taxonomy';
import styles from './Pricing.module.css';
type BillingPeriod = 'monthly' | 'annual';
type TierId = 'SOLO' | 'TEAMS';
interface TierDef {
readonly id: TierId;
readonly nsKey: 'solo' | 'teams';
readonly highlighted: boolean;
readonly bulletKeys: readonly string[];
readonly ctaType: 'download' | 'stripe';
}
/**
* Tier content mirrors `packages/shared/src/tiers.ts` (the canonical tier
* system): SOLO is free forever with full memory + Harvest, unlimited
* workspaces, marketplace, all connectors, and BYO cloud models; TEAMS adds
* shared workspaces, WaggleDance, and governance. No bullets beyond what
* tiers.ts encodes. The 15-day Team trial lives in the section subhead —
* it applies to every install, so listing it as a Solo feature misreads.
*/
const TIER_DEFS: readonly TierDef[] = [
{
id: 'SOLO',
nsKey: 'solo',
highlighted: false,
bulletKeys: [
'bullet_memory',
'bullet_workspaces',
'bullet_marketplace',
'bullet_models',
'bullet_skills',
],
ctaType: 'download',
},
{
id: 'TEAMS',
nsKey: 'teams',
highlighted: true,
bulletKeys: [
'bullet_everything_solo',
'bullet_shared',
'bullet_dance',
'bullet_governance',
],
ctaType: 'stripe',
},
];
const STRIPE_ENDPOINT =
(process.env.NEXT_PUBLIC_API_URL ?? '').replace(/\/$/, '') +
'/api/stripe/checkout';
const KVARK_URL = 'https://www.kvark.ai';
export default function Pricing() {
const t = useTranslations('landing.pricing');
const [billing, setBilling] = useState<BillingPeriod>('monthly');
const [checkoutCancelled, setCheckoutCancelled] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setCheckoutCancelled(params.get('checkout') === 'cancelled');
}, []);
const handleBillingChange = useCallback((mode: BillingPeriod) => {
setBilling(mode);
emit({ name: events.pricingBillingToggle, properties: { mode } });
}, []);
const handleStripeCtaClick = useCallback(
(tier: TierId) => {
emit({
name: events.ctaClick,
properties: { section: 'pricing', tier, billing },
});
},
[billing],
);
return (
<section id="pricing" className="section" aria-labelledby="pricing-heading">
<div className="container-wide">
<header className={styles.header}>
<p className="eyebrow">{t('eyebrow')}</p>
<h2 id="pricing-heading" className="section-headline">
{t('headline')}
</h2>
<p className="section-lead">{t('subhead')}</p>
</header>
<div
role="group"
aria-label={t('toggle.aria_group')}
className={styles.toggleRow}
>
<button
type="button"
onClick={() => handleBillingChange('monthly')}
className={[
styles.toggle,
billing === 'monthly' ? styles.toggleActive : '',
]
.filter(Boolean)
.join(' ')}
aria-pressed={billing === 'monthly'}
>
{t('toggle.monthly')}
</button>
<button
type="button"
onClick={() => handleBillingChange('annual')}
className={[
styles.toggle,
billing === 'annual' ? styles.toggleActive : '',
]
.filter(Boolean)
.join(' ')}
aria-pressed={billing === 'annual'}
>
{t('toggle.annual')}
<span className={styles.savePill}>{t('toggle.save_pill')}</span>
</button>
</div>
{checkoutCancelled ? (
<div role="status" className={styles.checkoutNotice}>
<span>{t('notices.cancelled')}</span>
<a
href={`${STRIPE_ENDPOINT}?tier=teams&billing=${billing}`}
onClick={() => handleStripeCtaClick('TEAMS')}
className={styles.noticeLink}
>
{t('notices.retry')}
</a>
</div>
) : null}
<div className={styles.grid}>
{TIER_DEFS.map((tier) => {
const priceKey =
billing === 'monthly' ? 'price_monthly' : 'price_annual';
const cardClass = [
styles.tierCard,
tier.highlighted ? styles.tierCardHighlighted : '',
]
.filter(Boolean)
.join(' ');
return (
<div key={tier.id} className={cardClass}>
{tier.highlighted ? (
<span className={styles.badge}>{t('popular_badge')}</span>
) : null}
<h3 className={styles.tierName}>
{t(`tiers.${tier.nsKey}.name`)}
</h3>
<p className={styles.tierTagline}>
{t(`tiers.${tier.nsKey}.tagline`)}
</p>
<p className={styles.price}>
{t(`tiers.${tier.nsKey}.${priceKey}`)}
</p>
<p className={styles.priceNote}>
{t(`tiers.${tier.nsKey}.note`)}
</p>
<ul className={styles.bullets}>
{tier.bulletKeys.map((bk) => (
<li key={bk} className={styles.bullet}>
<CheckIcon />
<span>{t(`tiers.${tier.nsKey}.${bk}`)}</span>
</li>
))}
</ul>
{tier.ctaType === 'download' ? (
<DownloadCTA
section="solo-tier"
variant={tier.highlighted ? 'primary' : 'ghost'}
style={{ width: '100%' }}
/>
) : (
<a
href={`${STRIPE_ENDPOINT}?tier=${tier.id.toLowerCase()}&billing=${billing}`}
onClick={() => handleStripeCtaClick(tier.id)}
className={[
'btn',
tier.highlighted ? 'btn-primary' : 'btn-ghost',
styles.tierCta,
].join(' ')}
>
{t(`tiers.${tier.nsKey}.cta`)}
</a>
)}
</div>
);
})}
{/* Enterprise = KVARK sovereign deployment. A quieter third card
(description, no checklist) so the grid fills its row without
inventing a tier — pricing stays consultative. */}
<div className={[styles.tierCard, styles.tierCardQuiet].join(' ')}>
<h3 className={styles.tierName}>{t('enterprise.name')}</h3>
<p className={styles.tierTagline}>{t('enterprise.tagline')}</p>
<p className={styles.price}>{t('enterprise.price')}</p>
<p className={styles.priceNote}>{t('enterprise.note')}</p>
<p className={styles.enterpriseBody}>{t('enterprise.text')}</p>
<a
href={KVARK_URL}
target="_blank"
rel="noopener noreferrer"
className={['btn', 'btn-ghost', styles.tierCta].join(' ')}
>
{t('enterprise.cta')}
</a>
</div>
</div>
</div>
</section>
);
}
function CheckIcon() {
return (
<svg
width="15"
height="15"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className={styles.bulletIcon}
>
<path
d="M3 8.5 L6.5 12 L13 4.5"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -0,0 +1,58 @@
.header {
max-width: 720px;
margin-bottom: 56px;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-bottom: 56px;
}
.beat {
padding: 26px 26px 28px;
border-radius: var(--r-lg);
background: var(--hive-900);
border: 1px solid var(--line-soft);
}
.beatIndex {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
display: block;
margin-bottom: 14px;
}
.beatTitle {
font-size: var(--text-h3);
font-weight: 600;
color: var(--hive-50);
margin-bottom: 10px;
}
.beatBody {
font-size: var(--text-small);
line-height: 1.6;
color: var(--hive-300);
}
.turn {
border-left: 2px solid var(--honey-500);
padding-left: 24px;
max-width: 640px;
}
.turnText {
font-size: clamp(1.125rem, 2vw, 1.375rem);
line-height: 1.55;
font-weight: 500;
color: var(--hive-100);
}
@media (max-width: 860px) {
.grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,51 @@
import { getTranslations } from 'next-intl/server';
import Reveal from './Reveal';
import styles from './ProblemTurn.module.css';
const BEATS = ['reintroduce', 'tabs', 'compound'] as const;
/**
* The problem statement ("every AI session starts from zero") in three
* beats, then the turn — Waggle's opposite bet — as a pull-quote with a
* honey rule. Strings under `landing.problem.*`.
*/
export default async function ProblemTurn() {
const t = await getTranslations('landing.problem');
return (
<section
id="problem"
className="section"
aria-labelledby="problem-heading"
>
<div className="container">
<header className={styles.header}>
<p className="eyebrow">{t('eyebrow')}</p>
<h2 id="problem-heading" className="section-headline">
{t('headline')}
</h2>
</header>
<div className={styles.grid}>
{BEATS.map((beat, i) => (
<Reveal key={beat} delay={(i + 1) as 1 | 2 | 3}>
<div className={styles.beat}>
<span className={styles.beatIndex} aria-hidden="true">
{String(i + 1).padStart(2, '0')}
</span>
<h3 className={styles.beatTitle}>{t(`beats.${beat}.title`)}</h3>
<p className={styles.beatBody}>{t(`beats.${beat}.body`)}</p>
</div>
</Reveal>
))}
</div>
<Reveal>
<div className={styles.turn}>
<p className={styles.turnText}>{t('turn')}</p>
</div>
</Reveal>
</div>
</section>
);
}

View File

@@ -0,0 +1,111 @@
.band {
background: var(--hive-900);
border-top: 1px solid var(--line-soft);
border-bottom: 1px solid var(--line-soft);
}
.header {
max-width: 760px;
margin-bottom: 56px;
}
.chart {
display: flex;
flex-direction: column;
gap: 22px;
max-width: 820px;
margin-bottom: 28px;
}
.row {
display: grid;
grid-template-columns: 200px 1fr 72px;
align-items: center;
gap: 18px;
}
.system {
display: flex;
flex-direction: column;
gap: 2px;
}
.systemName {
font-size: 14px;
font-weight: 600;
color: var(--hive-100);
}
.systemDetail {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.03em;
color: var(--text-muted);
}
.track {
display: block;
height: 30px;
border-radius: 7px;
background: var(--hive-800);
overflow: hidden;
}
.bar {
display: block;
height: 100%;
border-radius: 7px;
background: var(--hive-600);
transform-origin: left center;
}
.barHighlight {
background: linear-gradient(90deg, var(--honey-600) 0%, var(--honey-500) 100%);
box-shadow: var(--shadow-honey);
}
.score {
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
color: var(--hive-100);
text-align: right;
}
.scoreHighlight {
color: var(--honey-400);
}
.footnote {
font-family: var(--mono);
font-size: 11px;
line-height: 1.7;
color: var(--text-muted);
max-width: 720px;
margin-bottom: 36px;
}
.ctaRow {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
@media (max-width: 640px) {
.row {
grid-template-columns: 1fr 56px;
grid-template-rows: auto auto;
row-gap: 8px;
}
.system {
grid-column: 1 / -1;
flex-direction: row;
align-items: baseline;
gap: 10px;
}
.track {
height: 22px;
}
}

View File

@@ -0,0 +1,84 @@
import { getTranslations } from 'next-intl/server';
import Reveal from './Reveal';
import { LOCOMO_BARS } from '../_data/proof-points';
import styles from './ProofBand.module.css';
const HIVE_MIND_URL = 'https://github.com/marolinik/hive-mind';
/**
* Benchmark proof band. The chart is honest by construction: bar widths are
* raw scores on a 0100 axis (no truncated baseline), the protocol footnote
* names the judge, N, and significance, and both CTAs lead to verification
* paths (methodology page, reproducible repo). Data from `_data/proof-points`.
*/
export default async function ProofBand() {
const t = await getTranslations('landing.proof');
return (
<section
id="proof"
className={`section ${styles.band}`}
aria-labelledby="proof-heading"
>
<div className="container">
<header className={styles.header}>
<p className="eyebrow">{t('eyebrow')}</p>
<h2 id="proof-heading" className="section-headline">
{t('headline')}
</h2>
<p className="section-lead">{t('body')}</p>
</header>
<Reveal>
<div className={styles.chart} role="img" aria-label={t('chart_aria')}>
{LOCOMO_BARS.map((bar) => (
<div key={bar.id} className={styles.row}>
<span className={styles.system}>
<span className={styles.systemName}>{bar.system}</span>
<span className={styles.systemDetail}>{bar.detail}</span>
</span>
<span className={styles.track}>
<span
className={[
styles.bar,
bar.highlight ? styles.barHighlight : '',
]
.filter(Boolean)
.join(' ')}
style={{ width: `${bar.score}%` }}
/>
</span>
<span
className={[
styles.score,
bar.highlight ? styles.scoreHighlight : '',
]
.filter(Boolean)
.join(' ')}
>
{bar.score.toFixed(2)}%
</span>
</div>
))}
</div>
</Reveal>
<p className={styles.footnote}>{t('footnote')}</p>
<div className={styles.ctaRow}>
<a href="/docs/methodology" className="btn btn-ghost">
{t('cta_methodology')}
</a>
<a
href={HIVE_MIND_URL}
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost"
>
{t('cta_reproduce')}
</a>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
interface RevealProps {
readonly children: ReactNode;
/** Stagger slot 15 → transition-delay 60ms steps (see globals.css). */
readonly delay?: 1 | 2 | 3 | 4 | 5;
readonly as?: 'div' | 'section' | 'li' | 'span';
readonly className?: string;
readonly style?: CSSProperties;
}
/**
* Scroll-reveal wrapper: fades + lifts children in when they enter the
* viewport. Purely presentational — content is in the DOM at SSR (SEO-safe)
* and `prefers-reduced-motion` disables the effect entirely via globals.css.
*/
export default function Reveal({
children,
delay,
as: Tag = 'div',
className,
style,
}: RevealProps) {
const nodeRef = useRef<HTMLElement | null>(null);
const [visible, setVisible] = useState(false);
const setNode = useCallback((node: HTMLElement | null) => {
nodeRef.current = node;
}, []);
useEffect(() => {
const node = nodeRef.current;
if (!node) return;
if (typeof IntersectionObserver === 'undefined') {
setVisible(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
}
},
{ rootMargin: '0px 0px -10% 0px', threshold: 0.1 },
);
observer.observe(node);
// Safety net: if a section is never scrolled into view — a crawler, a
// social-preview renderer, or a full-page screenshot that paints without
// scrolling — the observer never fires and the content would stay stuck at
// opacity:0. Reveal it anyway shortly after mount so no section is ever a
// headline floating in an empty void. Real users scrolling normally still
// trip the observer first and get the entrance animation per section.
const fallback = window.setTimeout(() => setVisible(true), 900);
return () => {
observer.disconnect();
window.clearTimeout(fallback);
};
}, []);
const classes = [
'reveal',
visible ? 'is-visible' : '',
delay ? `reveal-d${delay}` : '',
className ?? '',
]
.filter(Boolean)
.join(' ');
return (
<Tag ref={setNode} className={classes} style={style}>
{children}
</Tag>
);
}

View File

@@ -0,0 +1,47 @@
.band {
background: var(--hive-900);
border-top: 1px solid var(--line-soft);
border-bottom: 1px solid var(--line-soft);
}
.header {
max-width: 720px;
margin-bottom: 56px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 32px;
margin-bottom: 48px;
}
.item {
border-top: 2px solid var(--honey-600);
padding-top: 20px;
}
.title {
font-size: 15px;
font-weight: 600;
color: var(--hive-50);
margin-bottom: 8px;
}
.body {
font-size: 13px;
line-height: 1.6;
color: var(--hive-300);
}
@media (max-width: 1023px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 560px) {
.grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,46 @@
import { getTranslations } from 'next-intl/server';
import Reveal from './Reveal';
import styles from './SovereigntyBand.module.css';
const ITEMS = ['device', 'egress', 'erasure', 'injection'] as const;
/**
* Data-sovereignty band: four specific, verifiable statements about where
* data lives and what leaves the machine. Trust through specificity — no
* compliance badges we don't hold. Strings under `landing.sovereignty.*`.
*/
export default async function SovereigntyBand() {
const t = await getTranslations('landing.sovereignty');
return (
<section
id="trust"
className={`section ${styles.band}`}
aria-labelledby="trust-heading"
>
<div className="container">
<header className={styles.header}>
<p className="eyebrow">{t('eyebrow')}</p>
<h2 id="trust-heading" className="section-headline">
{t('headline')}
</h2>
</header>
<div className={styles.grid}>
{ITEMS.map((item, i) => (
<Reveal key={item} delay={((i % 4) + 1) as 1 | 2 | 3 | 4}>
<div className={styles.item}>
<h3 className={styles.title}>{t(`items.${item}.title`)}</h3>
<p className={styles.body}>{t(`items.${item}.body`)}</p>
</div>
</Reveal>
))}
</div>
<a href="/eu-ai-act" className="btn btn-ghost btn-small">
{t('cta')}
</a>
</div>
</section>
);
}

View File

@@ -0,0 +1,125 @@
/**
* Canonical 13-persona data source for Waggle bee mascots.
*
* Copy is ratified per `PM-Waggle-OS/decisions/2026-04-22-personas-card-copy-locked.md`.
* Do NOT rewrite or paraphrase in consumer components — import from this module only.
*
* Asset paths are root-absolute (`/brand/...`) per Sesija D §1 ratification (c):
* the legacy Vite-relative form (`brand/...` resolved against `base: '/waggle/'`)
* was a GitHub Pages staging artifact, not production. waggle-os.ai does not
* apply a path prefix.
*/
export type PersonaSlug =
| 'hunter'
| 'researcher'
| 'analyst'
| 'connector'
| 'architect'
| 'builder'
| 'writer'
| 'orchestrator'
| 'marketer'
| 'team'
| 'celebrating'
| 'confused'
| 'sleeping';
export interface Persona {
/** Canonical slug used for keying, asset lookup, analytics callbacks. */
readonly slug: PersonaSlug;
/** Display title, e.g. "The Hunter". Verbatim from locked copy. */
readonly title: string;
/** One-line JTBD copy below the title. Verbatim from locked copy. */
readonly role: string;
/** Accessible image label — "Waggle {title} bee mascot". */
readonly alt: string;
/** Root-absolute asset path served from /public. */
readonly imagePath: string;
/** 1-13 canonical reading order (input → process → output → meta). */
readonly order: number;
}
function buildPersona(
slug: PersonaSlug,
title: string,
role: string,
order: number,
): Persona {
return {
slug,
title,
role,
alt: `Waggle ${title} bee mascot`,
imagePath: `/brand/bee-${slug}-dark.png`,
order,
};
}
export const personas: readonly Persona[] = [
buildPersona('hunter', 'The Hunter', 'Finds the source you forgot you saved.', 1),
buildPersona('researcher', 'The Researcher', 'Goes deep and brings back a verdict.', 2),
buildPersona('analyst', 'The Analyst', 'Sees the shape of what keeps repeating.', 3),
buildPersona(
'connector',
'The Connector',
"Links yesterday's thought to tomorrow's decision.",
4,
),
buildPersona(
'architect',
'The Architect',
'Gives chaos a structure you can reason about.',
5,
),
buildPersona('builder', 'The Builder', 'Turns a spec into something that ships.', 6),
buildPersona('writer', 'The Writer', 'Shapes the story the memory wants to tell.', 7),
buildPersona(
'orchestrator',
'The Orchestrator',
'Coordinates the agents, tools, and memory.',
8,
),
buildPersona(
'marketer',
'The Marketer',
'Translates what you do into what matters to them.',
9,
),
buildPersona('team', 'The Team', 'Many hands, one hive.', 10),
buildPersona(
'celebrating',
'The Milestone',
'Marks the moment when the work compounds.',
11,
),
buildPersona(
'confused',
'The Signal',
'Raises a flag when memory and reality disagree.',
12,
),
buildPersona(
'sleeping',
'The Night Shift',
'Consolidates while you rest — the hive never closes.',
13,
),
] as const;
/** O(1) lookup by slug. */
export const personaBySlug: Readonly<Record<PersonaSlug, Persona>> = Object.freeze(
personas.reduce(
(acc, persona) => {
acc[persona.slug] = persona;
return acc;
},
{} as Record<PersonaSlug, Persona>,
),
);
/**
* Path to the hex-texture PNG used as filler-tile background and as
* placeholder canvas when a persona asset fails to load.
*/
export const HEX_TEXTURE_PATH = '/brand/hex-texture-dark.png';

View File

@@ -0,0 +1,48 @@
/**
* Verified LoCoMo benchmark data for the proof band (86.49 SOTA arc,
* 2026-06; the earlier 87.66 figure was withdrawn 2026-07-01 as
* unreproducible — never surface it).
*
* Protocol: LoCoMo, N=1,540, GPT-4.1-mini as answerer AND judge — the prior
* leader's (Memori) published protocol, reproduced in-harness before
* comparing. Waggle (hive-mind substrate) 86.49% vs Memori 81.95%
* (+4.54pp, z=4.64, p<10⁻⁵); Mem0 re-measured under the same protocol:
* 73.96%. Single-hop: 92.27%. Sources: benchmarks/results/locomo-sota-2026-06/
* and docs/methodology.md §0. Numbers MUST match those files. Do not
* fabricate; do not rescale chart axes away from zero.
*/
export interface BenchmarkBar {
readonly id: string;
readonly system: string;
readonly detail: string;
readonly score: number;
readonly highlight: boolean;
}
export const LOCOMO_BARS: readonly BenchmarkBar[] = Object.freeze([
{
id: 'waggle',
system: 'Waggle (hive-mind)',
detail: 'open source · runs locally',
score: 86.49,
highlight: true,
},
{
id: 'memori',
system: 'Memori',
detail: 'prior state of the art',
score: 81.95,
highlight: false,
},
{
id: 'mem0',
system: 'Mem0',
detail: 're-measured, same protocol',
score: 73.96,
highlight: false,
},
]);
/** Secondary verified stat: single-hop recall. */
export const SINGLE_HOP_SCORE = 92.27;

View File

@@ -0,0 +1,24 @@
/**
* Stub event taxonomy for the v3.2 landing per amendment §2.1.
*
* Logs to console in development; no-op in production. Replace with a real
* analytics provider (PostHog / Plausible) in Phase 2.
*/
export interface LandingEvent {
readonly name: string;
readonly properties?: Record<string, unknown>;
}
export const events = {
pageView: 'landing.page_view',
sectionVisible: 'landing.section_visible',
ctaClick: 'landing.cta_click',
pricingBillingToggle: 'landing.pricing.billing_toggle.changed',
} as const;
export function emit(event: LandingEvent): void {
if (process.env.NODE_ENV === 'production') return;
// eslint-disable-next-line no-console
console.info('[landing.event]', event.name, event.properties ?? {});
}

View File

@@ -0,0 +1,15 @@
export type OSId = 'macOS' | 'Windows' | 'Linux';
/**
* Best-effort desktop OS detection from a User-Agent string.
*
* Mobile/tablet visitors return null so download CTAs stay generic instead of
* promising a desktop installer for the wrong platform.
*/
export function detectOSFromUserAgent(ua: string): OSId | null {
if (/iPhone|iPad|iPod|Android|Mobile|Tablet/i.test(ua)) return null;
if (/Mac/i.test(ua)) return 'macOS';
if (/Windows/i.test(ua)) return 'Windows';
if (/Linux/i.test(ua)) return 'Linux';
return null;
}

View File

@@ -0,0 +1,39 @@
import type { CSSProperties } from 'react';
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
import { UserProfile } from '@clerk/nextjs';
/**
* Account management page (Sesija E §5.2).
*
* Server component: gates access via `await auth()`. Unauthenticated users
* are redirected to /sign-in before any Clerk component mounts. Authenticated
* users see Clerk's <UserProfile>, which handles email/password updates,
* connected accounts, sessions, security settings.
*
* Stripe Customer ID surfaced in user.publicMetadata is set by §5.3 webhooks
* (apps/www/app/api/webhooks/clerk/route.ts) on the user.created event.
*
* Appearance is inherited from <ClerkProvider> in app/layout.tsx (Hive DS).
*/
export default async function AccountPage() {
const { userId } = await auth();
if (!userId) {
redirect('/sign-in');
}
return (
<main style={pageStyle}>
<UserProfile />
</main>
);
}
const pageStyle: CSSProperties = {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '96px 24px 48px',
background: 'var(--hive-950, #0e0c07)',
};

View File

@@ -0,0 +1,284 @@
import { NextResponse } from 'next/server';
import { auth, clerkClient } from '@clerk/nextjs/server';
import Stripe from 'stripe';
/**
* /api/stripe/checkout — Stripe Checkout Session creator with Clerk linkage.
*
* Sesija E §5.3 Phase C: lazy-create Stripe Customer pattern. On first
* paid checkout for a user we create the Customer, store its id in
* `Clerk.user.publicMetadata.stripeCustomerId`, and reuse it forever after.
* Customer.metadata.clerkUserId mirrors the linkage in the other direction
* so subscription webhooks can map back to a Clerk user.
*
* GET ?tier=teams&billing=monthly|annual
* - Canonical entrypoint (per §5.3 brief). Used by Clerk SignUp's
* `forceRedirectUrl` after sign-up completion.
* - Returns 303 redirect to the Stripe Checkout URL on success.
* - Signed-out: 303 to /sign-in with redirect_url back to this endpoint.
*
* POST { tier, billingPeriod }
* - Backward-compat shim for older clients. Pricing.tsx now uses the
* canonical GET flow.
* - Returns JSON { url } on success or { message } on error.
* - Signed-out: 401 JSON { message }.
*/
// New checkout is TEAMS-only (Solo is free). Legacy 'pro' is rejected here;
// legacy pro subscription webhooks are still honored in the webhook route.
type Tier = 'teams';
type Billing = 'monthly' | 'annual';
const TIERS: readonly Tier[] = ['teams'];
const BILLINGS: readonly Billing[] = ['monthly', 'annual'];
interface ClerkPublicMetadata {
readonly stripeCustomerId?: string;
readonly subscriptionTier?: Tier;
readonly subscriptionStatus?:
| 'active'
| 'past_due'
| 'canceled'
| 'trialing'
| 'incomplete';
}
interface CheckoutSuccess {
readonly kind: 'success';
readonly url: string;
}
interface CheckoutAuthRedirect {
readonly kind: 'auth_required';
readonly signInUrl: string;
}
interface CheckoutFailure {
readonly kind: 'failure';
readonly status: number;
readonly message: string;
}
type CheckoutResult = CheckoutSuccess | CheckoutAuthRedirect | CheckoutFailure;
function normalizeTier(value: unknown): Tier | null {
if (typeof value !== 'string') return null;
const lower = value.toLowerCase();
return TIERS.includes(lower as Tier) ? (lower as Tier) : null;
}
function normalizeBilling(value: unknown): Billing | null {
if (typeof value !== 'string') return null;
const lower = value.toLowerCase();
return BILLINGS.includes(lower as Billing) ? (lower as Billing) : null;
}
function isValidStripeKey(key: string | undefined): key is string {
return (
typeof key === 'string' &&
(key.startsWith('sk_test_') || key.startsWith('sk_live_'))
);
}
async function ensureStripeCustomer(
userId: string,
email: string | null,
existingId: string | undefined,
stripe: Stripe,
): Promise<string> {
if (existingId) return existingId;
const customer = await stripe.customers.create({
email: email ?? undefined,
metadata: { clerkUserId: userId },
});
const cc = await clerkClient();
await cc.users.updateUserMetadata(userId, {
publicMetadata: { stripeCustomerId: customer.id } satisfies ClerkPublicMetadata,
});
return customer.id;
}
async function resolvePriceId(
stripe: Stripe,
tier: Tier,
billing: Billing,
): Promise<string | null> {
// Prefer env-pinned IDs (zero round-trip). Fall back to lookup_key resolution
// so the route still works in environments where price IDs aren't pinned.
const envKey = `STRIPE_PRICE_${tier.toUpperCase()}_${billing.toUpperCase()}`;
const pinned = process.env[envKey];
if (pinned && pinned.startsWith('price_')) return pinned;
const lookupKey = `${tier}_${billing}`;
const list = await stripe.prices.list({
lookup_keys: [lookupKey],
active: true,
limit: 1,
});
return list.data[0]?.id ?? null;
}
async function runCheckout(
origin: string,
tier: Tier,
billing: Billing,
): Promise<CheckoutResult> {
const { userId } = await auth();
if (!userId) {
const target = `/api/stripe/checkout?tier=${tier}&billing=${billing}`;
return {
kind: 'auth_required',
signInUrl: `${origin}/sign-in?redirect_url=${encodeURIComponent(target)}`,
};
}
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!isValidStripeKey(secretKey)) {
return {
kind: 'failure',
status: 503,
message:
'Stripe checkout configuration required. Set STRIPE_SECRET_KEY in env (sk_test_* or sk_live_*).',
};
}
const stripe = new Stripe(secretKey);
const cc = await clerkClient();
const user = await cc.users.getUser(userId);
const publicMetadata = (user.publicMetadata ?? {}) as ClerkPublicMetadata;
const email = user.primaryEmailAddress?.emailAddress ?? null;
const customerId = await ensureStripeCustomer(
userId,
email,
publicMetadata.stripeCustomerId,
stripe,
);
const priceId = await resolvePriceId(stripe, tier, billing);
if (!priceId) {
return {
kind: 'failure',
status: 503,
message: `No active Stripe price found for ${tier}/${billing}. Set STRIPE_PRICE_${tier.toUpperCase()}_${billing.toUpperCase()} or assign lookup_key="${tier}_${billing}".`,
};
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/account?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/?checkout=cancelled#pricing`,
metadata: { clerkUserId: userId, tier, billing },
subscription_data: {
metadata: { clerkUserId: userId, tier, billing },
},
});
if (!session.url) {
return {
kind: 'failure',
status: 500,
message: 'Stripe session created without redirect URL',
};
}
return { kind: 'success', url: session.url };
}
function originOf(req: Request): string {
const headerOrigin = req.headers.get('origin');
if (headerOrigin) return headerOrigin;
return new URL(req.url).origin;
}
async function safeRunCheckout(
origin: string,
tier: Tier,
billing: Billing,
): Promise<CheckoutResult> {
try {
return await runCheckout(origin, tier, billing);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Stripe API error';
return { kind: 'failure', status: 500, message };
}
}
export async function GET(req: Request): Promise<Response> {
const url = new URL(req.url);
const tier = normalizeTier(url.searchParams.get('tier'));
const billing = normalizeBilling(url.searchParams.get('billing'));
if (!tier || !billing) {
return NextResponse.json(
{
message:
'Invalid query. Expected ?tier=teams&billing=monthly|annual.',
},
{ status: 400 },
);
}
const result = await safeRunCheckout(originOf(req), tier, billing);
switch (result.kind) {
case 'success':
return Response.redirect(result.url, 303);
case 'auth_required':
return Response.redirect(result.signInUrl, 303);
case 'failure':
return NextResponse.json(
{ message: result.message },
{ status: result.status },
);
}
}
export async function POST(req: Request): Promise<NextResponse> {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ message: 'Invalid JSON body' }, { status: 400 });
}
if (typeof body !== 'object' || body === null) {
return NextResponse.json({ message: 'Invalid body' }, { status: 400 });
}
const obj = body as Record<string, unknown>;
const tier = normalizeTier(obj.tier);
// Accept legacy field name `billingPeriod` from existing Pricing.tsx
// alongside the new canonical `billing`.
const billing = normalizeBilling(obj.billingPeriod ?? obj.billing);
if (!tier || !billing) {
return NextResponse.json(
{
message:
'Invalid body. Expected { tier: "teams", billingPeriod: "monthly"|"annual" }.',
},
{ status: 400 },
);
}
const result = await safeRunCheckout(originOf(req), tier, billing);
switch (result.kind) {
case 'success':
return NextResponse.json({ url: result.url });
case 'auth_required':
return NextResponse.json(
{ message: 'Sign in required', signInUrl: result.signInUrl },
{ status: 401 },
);
case 'failure':
return NextResponse.json(
{ message: result.message },
{ status: result.status },
);
}
}

View File

@@ -0,0 +1,224 @@
import { NextResponse } from 'next/server';
import { clerkClient } from '@clerk/nextjs/server';
import Stripe from 'stripe';
/**
* /api/webhooks/stripe — Stripe webhook receiver (Sesija E §5.3 Phase C).
*
* Verifies signature against STRIPE_WEBHOOK_SECRET, then mirrors subscription
* state from Stripe → Clerk user.publicMetadata so the Next.js app can gate
* features on tier/status without round-tripping to Stripe on every request.
*
* Handled events:
* checkout.session.completed → set tier + status='active'
* customer.subscription.updated → map status, refresh tier
* customer.subscription.deleted → status='canceled'
*
* Linkage strategy: every Checkout Session and Subscription gets
* `metadata.clerkUserId` set by the checkout route. As a fallback, the Stripe
* Customer also carries `metadata.clerkUserId` (set during lazy-create), so
* subscription events that lack metadata can still resolve the user.
*
* Returns 200 quickly so Stripe's retry queue stays clean. Handler errors
* surface as 500 (Stripe will retry up to its standard backoff schedule).
*/
// New checkout is TEAMS-only, but this webhook still accepts legacy 'pro'
// subscription events so existing subscribers keep getting status updates
// (renewals, cancellations). Pro is no longer a sold tier — the app coerces
// it to the free Solo label at display time (parseTier('PRO') → 'FREE').
type Tier = 'pro' | 'teams';
interface ClerkPublicMetadata {
readonly stripeCustomerId?: string;
readonly subscriptionTier?: Tier;
readonly subscriptionStatus?:
| 'active'
| 'past_due'
| 'canceled'
| 'trialing'
| 'incomplete';
}
function isValidStripeKey(key: string | undefined): key is string {
return (
typeof key === 'string' &&
(key.startsWith('sk_test_') || key.startsWith('sk_live_'))
);
}
function isValidWebhookSecret(value: string | undefined): value is string {
return typeof value === 'string' && value.startsWith('whsec_');
}
function asTier(value: unknown): Tier | undefined {
return value === 'pro' || value === 'teams' ? value : undefined;
}
function mapStatus(
status: Stripe.Subscription.Status,
): NonNullable<ClerkPublicMetadata['subscriptionStatus']> {
// Collapse Stripe's 8 statuses into the 5 we expose to the app:
// active | past_due | canceled | trialing | incomplete
switch (status) {
case 'active':
case 'past_due':
case 'canceled':
case 'trialing':
case 'incomplete':
return status;
case 'unpaid':
case 'incomplete_expired':
return 'past_due';
case 'paused':
return 'canceled';
default:
return 'incomplete';
}
}
async function findClerkUserIdFromCustomer(
customerId: string,
stripe: Stripe,
): Promise<string | null> {
const customer = await stripe.customers.retrieve(customerId);
if ('deleted' in customer && customer.deleted) return null;
const meta = (customer as Stripe.Customer).metadata;
return meta?.clerkUserId ?? null;
}
async function patchClerkPublicMetadata(
userId: string,
patch: Partial<ClerkPublicMetadata>,
): Promise<void> {
const cc = await clerkClient();
const user = await cc.users.getUser(userId);
const current = (user.publicMetadata ?? {}) as ClerkPublicMetadata;
await cc.users.updateUserMetadata(userId, {
publicMetadata: { ...current, ...patch } satisfies ClerkPublicMetadata,
});
}
async function handleCheckoutCompleted(
event: Stripe.CheckoutSessionCompletedEvent,
): Promise<void> {
const session = event.data.object;
const userId = session.metadata?.clerkUserId;
const tier = asTier(session.metadata?.tier);
// R1-002 (webhook path): only grant once payment has settled.
// checkout.session.completed also fires for unpaid/async sessions.
const paid =
session.payment_status === 'paid' ||
session.payment_status === 'no_payment_required';
if (!userId || !tier || !paid) return;
await patchClerkPublicMetadata(userId, {
subscriptionTier: tier,
subscriptionStatus: 'active',
});
}
async function handleSubscriptionUpdated(
event: Stripe.CustomerSubscriptionUpdatedEvent,
stripe: Stripe,
): Promise<void> {
const sub = event.data.object;
const userId =
sub.metadata?.clerkUserId ??
(typeof sub.customer === 'string'
? await findClerkUserIdFromCustomer(sub.customer, stripe)
: null);
if (!userId) return;
const tier = asTier(sub.metadata?.tier);
const patch: ClerkPublicMetadata = {
subscriptionStatus: mapStatus(sub.status),
...(tier ? { subscriptionTier: tier } : {}),
};
await patchClerkPublicMetadata(userId, patch);
}
async function handleSubscriptionDeleted(
event: Stripe.CustomerSubscriptionDeletedEvent,
stripe: Stripe,
): Promise<void> {
const sub = event.data.object;
const userId =
sub.metadata?.clerkUserId ??
(typeof sub.customer === 'string'
? await findClerkUserIdFromCustomer(sub.customer, stripe)
: null);
if (!userId) return;
await patchClerkPublicMetadata(userId, { subscriptionStatus: 'canceled' });
}
export async function POST(req: Request): Promise<NextResponse> {
const secretKey = process.env.STRIPE_SECRET_KEY;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!isValidStripeKey(secretKey)) {
return NextResponse.json(
{ message: 'Stripe not configured (STRIPE_SECRET_KEY missing/invalid)' },
{ status: 503 },
);
}
if (!isValidWebhookSecret(webhookSecret)) {
return NextResponse.json(
{
message:
'Webhook secret not configured (STRIPE_WEBHOOK_SECRET missing/invalid)',
},
{ status: 503 },
);
}
const sig = req.headers.get('stripe-signature');
if (!sig) {
return NextResponse.json(
{ message: 'Missing stripe-signature header' },
{ status: 400 },
);
}
const rawBody = await req.text();
const stripe = new Stripe(secretKey);
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Invalid signature';
return NextResponse.json(
{ message: `Signature verification failed: ${msg}` },
{ status: 400 },
);
}
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event);
break;
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event, stripe);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event, stripe);
break;
default:
// Unhandled events ack with 200 — Stripe will keep delivering them
// even if we don't act, so just no-op rather than returning an error.
break;
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Handler error';
return NextResponse.json(
{ message: `Handler error: ${msg}`, eventType: event.type },
{ status: 500 },
);
}
return NextResponse.json({ received: true, eventType: event.type });
}

View File

@@ -0,0 +1,38 @@
import type { Metadata } from 'next';
import BrandPersonasCard from '@/app/_components/BrandPersonasCard';
/**
* Isolated preview route for the `BrandPersonasCard` landing variant.
*
* Served at `/design/personas` (Next.js App Router). No nav chrome.
* Used for visual QA, hand-off to design, and as the canonical reference.
* Discovery is URL-only — not linked from the main landing, robots blocked
* via the `metadata.robots` export below.
*
* `BrandPersonasCard` is a Client Component (uses `useState`/`useCallback`),
* but this page itself is a Server Component — Next.js App Router supports
* server→client composition, and the metadata export requires a server
* component context.
*/
export const metadata: Metadata = {
title: 'Waggle — Personas Preview',
description:
'Internal preview route for the BrandPersonasCard component. Not indexed.',
robots: { index: false, follow: false },
};
export default function DesignPersonasPage() {
return (
<main
style={{
minHeight: '100vh',
background: 'var(--hive-950, #0e0c07)',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
}}
>
<BrandPersonasCard variant="landing" />
</main>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

View File

@@ -0,0 +1,253 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { CSSProperties } from 'react';
import type { Metadata } from 'next';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import BrandMark from '../../_components/BrandMark';
/**
* /docs/methodology — Day 0 Trust Band Card 4 link target per Path D landing
* decoupling (PM 2026-05-02).
*
* Renders `docs/methodology.md` (committed at repo root, see SHA `7d1e0fc`)
* via react-markdown + remark-gfm. The file is read at module-load time;
* combined with `force-static`, the markdown is baked into the build output
* — no per-request file I/O, no runtime fs dependency.
*
* Path resolution: `process.cwd()` at `next build` is `apps/www/`; going up
* two levels (`../../docs/methodology.md`) hits the repo-root docs directory.
*
* Visual design per PM ratification: hive-950 background, hive-100 body
* text, honey-500 link accent. No nav, no sidebar — single-page docs with
* a back-to-landing link in the footer.
*/
const METHODOLOGY_MD = readFileSync(
resolve(process.cwd(), '..', '..', 'docs', 'methodology.md'),
'utf-8',
);
export const dynamic = 'force-static';
export const metadata: Metadata = {
title: 'Methodology — Waggle',
description:
"How Waggle's memory is evaluated: LoCoMo (N=1,540) under the prior leader's own protocol and judge (GPT-4.1-mini answerer+judge) — 86.49%, a new state of the art (+4.54pp over the prior best), reproducible offline.",
alternates: { canonical: 'https://waggle-os.ai/docs/methodology' },
robots: { index: true, follow: true },
};
export default function MethodologyPage() {
return (
<main style={pageStyle}>
<header style={headerStyle}>
<a href="/" style={brandLinkStyle}>
<BrandMark withWordmark />
<span style={separatorStyle}>·</span>
<span style={crumbStyle}>Methodology</span>
</a>
</header>
<article style={articleStyle} className="methodology-prose">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{METHODOLOGY_MD}</ReactMarkdown>
</article>
<footer style={footerStyle}>
<a href="/" style={backLinkStyle}>
Back to Waggle
</a>
</footer>
<style>{scopedCss}</style>
</main>
);
}
const pageStyle: CSSProperties = {
minHeight: '100vh',
background: 'var(--hive-950, #0e0c07)',
color: 'var(--hive-100, #ece3d0)',
fontFamily: "var(--sans)",
paddingTop: 24,
paddingBottom: 48,
};
const headerStyle: CSSProperties = {
maxWidth: 800,
margin: '0 auto 48px',
padding: '0 24px',
};
const brandLinkStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 10,
textDecoration: 'none',
color: 'var(--hive-200, #d8cfba)',
fontSize: 14,
};
const separatorStyle: CSSProperties = {
color: 'var(--hive-400, #948a73)',
};
const crumbStyle: CSSProperties = {
color: 'var(--hive-300, #c8bfa9)',
};
const articleStyle: CSSProperties = {
maxWidth: 800,
margin: '0 auto',
padding: '0 24px',
};
const footerStyle: CSSProperties = {
maxWidth: 800,
margin: '64px auto 0',
padding: '24px',
borderTop: '1px solid var(--hive-700, #272117)',
textAlign: 'center',
};
const backLinkStyle: CSSProperties = {
display: 'inline-block',
fontSize: 14,
color: 'var(--honey-400, #f6c45a)',
textDecoration: 'none',
fontWeight: 600,
};
/* Markdown prose styling — scoped to .methodology-prose */
const scopedCss = `
.methodology-prose {
font-size: 16px;
line-height: 1.65;
color: var(--hive-100, #ece3d0);
}
.methodology-prose h1 {
font-size: clamp(28px, 4vw, 36px);
font-weight: 800;
color: var(--hive-50, #f6f1e4);
margin-top: 0;
margin-bottom: 24px;
letter-spacing: -0.01em;
}
.methodology-prose h2 {
font-size: clamp(22px, 3vw, 26px);
font-weight: 700;
color: var(--hive-50, #f6f1e4);
margin-top: 48px;
margin-bottom: 16px;
}
.methodology-prose h3 {
font-size: 18px;
font-weight: 600;
color: var(--hive-100, #ece3d0);
margin-top: 32px;
margin-bottom: 12px;
}
.methodology-prose h4 {
font-size: 15px;
font-weight: 600;
color: var(--hive-200, #d8cfba);
margin-top: 24px;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.methodology-prose p {
margin: 0 0 16px;
color: var(--hive-200, #d8cfba);
}
.methodology-prose a {
color: var(--honey-400, #f6c45a);
text-decoration: underline;
text-decoration-color: var(--honey-600, #c07e16);
text-underline-offset: 3px;
}
.methodology-prose a:hover {
color: var(--honey-300, #f9d27e);
}
.methodology-prose strong {
color: var(--hive-50, #f6f1e4);
font-weight: 600;
}
.methodology-prose em {
color: var(--hive-100, #ece3d0);
font-style: italic;
}
.methodology-prose ul,
.methodology-prose ol {
margin: 0 0 20px;
padding-left: 24px;
color: var(--hive-200, #d8cfba);
}
.methodology-prose li {
margin-bottom: 8px;
}
.methodology-prose li > p {
margin-bottom: 8px;
}
.methodology-prose code {
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
font-size: 0.88em;
padding: 2px 6px;
border-radius: 4px;
background: var(--hive-900, #14110b);
color: var(--honey-300, #f9d27e);
border: 1px solid var(--hive-800, #1f1a12);
}
.methodology-prose pre {
margin: 16px 0 24px;
padding: 16px 20px;
background: var(--hive-900, #14110b);
border: 1px solid var(--hive-700, #272117);
border-radius: 8px;
overflow-x: auto;
}
.methodology-prose pre code {
background: transparent;
border: none;
padding: 0;
color: var(--hive-100, #ece3d0);
font-size: 13px;
line-height: 1.5;
}
.methodology-prose blockquote {
margin: 0 0 20px;
padding: 4px 16px;
border-left: 3px solid var(--honey-500, #e9a52c);
background: rgba(233, 165, 44, 0.04);
color: var(--hive-200, #d8cfba);
font-style: italic;
}
.methodology-prose blockquote p {
margin-bottom: 8px;
}
.methodology-prose hr {
margin: 40px 0;
border: none;
border-top: 1px solid var(--hive-700, #272117);
}
.methodology-prose table {
width: 100%;
border-collapse: collapse;
margin: 16px 0 24px;
font-size: 14px;
}
.methodology-prose th,
.methodology-prose td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--hive-800, #1f1a12);
}
.methodology-prose th {
background: var(--hive-850, #1a160f);
color: var(--hive-100, #ece3d0);
font-weight: 600;
border-bottom: 1px solid var(--hive-700, #272117);
}
.methodology-prose td {
color: var(--hive-200, #d8cfba);
}
`;

View File

@@ -0,0 +1,115 @@
import type { CSSProperties } from 'react';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Download Waggle',
description:
'Download status and installer availability for Waggle OS desktop.',
};
const HEADING = 'Download Waggle';
const STATUS =
'Windows and macOS installers are being prepared for the signed public release.';
const BODY =
'Until those installers are published, you can inspect the source repository or contact Egzakta for early access.';
const SOURCE_CTA = 'View source on GitHub';
const CONTACT_CTA = 'Request installer access';
const SOURCE_URL = 'https://github.com/marolinik/waggle-os';
const CONTACT_URL = 'mailto:hello@egzakta.com?subject=Waggle%20installer%20access';
export default function DownloadPage() {
return (
<main style={pageStyle}>
<section style={sectionStyle} aria-labelledby="download-heading">
<p style={eyebrowStyle}>Desktop app</p>
<h1 id="download-heading" style={h1Style}>
{HEADING}
</h1>
<p style={statusStyle}>{STATUS}</p>
<p style={bodyStyle}>{BODY}</p>
<div style={actionsStyle}>
<a href={SOURCE_URL} style={primaryLinkStyle}>
{SOURCE_CTA}
</a>
<a href={CONTACT_URL} style={secondaryLinkStyle}>
{CONTACT_CTA}
</a>
</div>
</section>
</main>
);
}
const pageStyle: CSSProperties = {
minHeight: '100vh',
padding: '120px 24px 80px',
background: 'var(--page, #0e0c07)',
color: 'var(--hive-100, #ece3d0)',
};
const sectionStyle: CSSProperties = {
maxWidth: 760,
margin: '0 auto',
};
const eyebrowStyle: CSSProperties = {
color: 'var(--honey-400, #f6c45a)',
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
marginBottom: 16,
};
const h1Style: CSSProperties = {
color: 'var(--hive-50, #f6f1e4)',
fontSize: 'clamp(36px, 7vw, 68px)',
lineHeight: 1,
margin: '0 0 24px',
};
const statusStyle: CSSProperties = {
color: 'var(--hive-100, #ece3d0)',
fontSize: 'clamp(18px, 2.4vw, 24px)',
lineHeight: 1.45,
margin: '0 0 14px',
maxWidth: 680,
};
const bodyStyle: CSSProperties = {
color: 'var(--hive-300, #948a73)',
fontSize: 16,
lineHeight: 1.7,
margin: '0 0 32px',
maxWidth: 640,
};
const actionsStyle: CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: 12,
};
const baseLinkStyle: CSSProperties = {
borderRadius: 8,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 44,
padding: '0 18px',
fontWeight: 700,
textDecoration: 'none',
};
const primaryLinkStyle: CSSProperties = {
...baseLinkStyle,
background: 'var(--honey-500, #e9a52c)',
color: 'var(--hive-950, #0e0c07)',
};
const secondaryLinkStyle: CSSProperties = {
...baseLinkStyle,
border: '1px solid var(--line-soft, rgba(236, 227, 208, 0.14))',
color: 'var(--hive-100, #ece3d0)',
};

364
apps/www/app/globals.css Normal file
View File

@@ -0,0 +1,364 @@
/* ── Waggle Landing Page — Warm-Hive Design System ── */
/* No Tailwind — pure CSS custom properties + CSS Modules per component. */
/* Token VALUES are the warm-Hive palette (PR8), 1:1 with the product app. */
/* Canonical source: docs/design_handoff_waggle_app/design-files/styles/waggle.css §7 */
:root {
/* Warm graphite hive scale (replaces the cool blue-grey scale) */
--hive-950: #0e0c07;
--hive-900: #14110b;
--hive-850: #1a160f;
--hive-800: #1f1a12;
--hive-700: #272117;
--hive-600: #4a4030;
--hive-500: #6b6250;
--hive-400: #948a73;
--hive-300: #c8bfa9;
--hive-200: #d8cfba;
--hive-100: #ece3d0;
--hive-50: #f6f1e4;
/* Honey accent (warmer, replaces the prior honey family) */
--honey-600: #c07e16;
--honey-500: #e9a52c;
--honey-400: #f6c45a;
--honey-300: #f9d27e;
--honey-glow: rgba(233, 165, 44, 0.12);
--honey-pulse: rgba(233, 165, 44, 0.06);
--shadow-honey: 0 0 24px rgba(233,165,44,0.12), 0 0 4px rgba(233,165,44,0.08);
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.55), 0 2px 4px rgba(0,0,0,0.4);
/* Named warm tokens (from waggle.css section 7) */
--bg: #14110b;
--bg-2: #1a160f;
--surface: #1f1a12;
--surface-2: #272117;
--surface-3: #322a1d;
--line: #38301f;
--line-soft: #2a2417;
--line-strong: #4a4030;
--text: #f6f1e4;
--text-2: #c8bfa9;
--text-muted: #948a73;
--text-dim: #6b6250;
--honey: #e9a52c;
--honey-bright: #f6c45a;
--honey-deep: #c07e16;
--honey-wash: rgba(233, 165, 44, 0.10);
--honey-line: rgba(233, 165, 44, 0.28);
--work: #7aa6d6;
--intel: #b196dd;
--healthy: #6cb78c;
--attention: #e9a52c;
--risk: #db8068;
--status-ai: #b196dd;
--status-healthy: #6cb78c;
--r-sm: 8px;
--r: 12px;
--r-lg: 18px;
--r-xl: 26px;
--sans: var(--font-hanken), 'Hanken Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--mono: var(--font-mono), 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace;
/* ── Type scale (marketing foundation) ── */
--text-display: clamp(2.5rem, 5.4vw, 4.25rem); /* hero h1 */
--text-h2: clamp(1.75rem, 3.1vw, 2.5rem); /* section headline */
--text-h3: clamp(1.125rem, 1.6vw, 1.3125rem); /* card / step titles */
--text-lead: clamp(1.0625rem, 1.5vw, 1.1875rem); /* section subhead */
--text-body: 1rem;
--text-small: 0.875rem;
--text-xs: 0.75rem;
/* ── Layout rhythm ── */
--container: 1120px;
--container-wide: 1200px;
--section-pad: clamp(80px, 11vw, 144px);
--gutter: 24px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: var(--sans);
background: var(--hive-950);
color: var(--text);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
min-height: 100vh;
background: var(--hive-950);
font-family: var(--sans);
}
/* ── Scrollbar — thin warm overlay (the stock Windows scrollbar reads as
chrome on the hero). Standard properties cover Firefox + modern
Chromium; the ::-webkit-* rules cover Safari + older Chromium. ── */
html {
scrollbar-width: thin;
scrollbar-color: var(--hive-600) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--hive-600);
border-radius: 999px;
/* Transparent border + padding-box clip = a slimmer overlay-style thumb. */
border: 3px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--hive-500);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--sans);
font-weight: 600;
letter-spacing: -0.02em;
text-wrap: balance;
}
h1 { letter-spacing: -0.03em; }
p { text-wrap: pretty; }
::selection {
background: rgba(233, 165, 44, 0.28);
color: var(--hive-50);
}
:focus-visible {
outline: 2px solid var(--honey-500);
outline-offset: 3px;
border-radius: 4px;
}
/* Fixed 64px navbar: anchor-jump targets (/#pricing, /#personas, …) must
land with their headline clear of the nav, not occluded under it. */
[id] {
scroll-margin-top: 80px;
}
/* ── Skip link (a11y) ── */
.skip-link {
position: absolute;
top: -48px;
left: 16px;
z-index: 200;
padding: 10px 18px;
border-radius: var(--r-sm);
background: var(--honey-500);
color: var(--hive-950);
font-size: var(--text-small);
font-weight: 600;
text-decoration: none;
transition: top 0.15s ease;
}
.skip-link:focus-visible {
top: 12px;
}
/* ── Shared layout primitives ── */
.container {
max-width: var(--container);
margin: 0 auto;
padding-left: var(--gutter);
padding-right: var(--gutter);
}
.container-wide {
max-width: var(--container-wide);
margin: 0 auto;
padding-left: var(--gutter);
padding-right: var(--gutter);
}
.section {
padding-top: var(--section-pad);
padding-bottom: var(--section-pad);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--mono);
font-size: var(--text-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--honey-500);
margin-bottom: 16px;
}
.section-headline {
font-size: var(--text-h2);
font-weight: 700;
line-height: 1.12;
color: var(--hive-50);
max-width: 22em;
}
.section-lead {
font-size: var(--text-lead);
line-height: 1.6;
color: var(--hive-300);
max-width: 40em;
margin-top: 16px;
}
/* Machine-truth line: mono, muted — the signature device for verifiable facts */
.mono-line {
font-family: var(--mono);
font-size: var(--text-xs);
letter-spacing: 0.02em;
color: var(--text-muted);
}
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 28px;
border-radius: var(--r);
font-family: var(--sans);
font-size: var(--text-small);
font-weight: 600;
line-height: 1;
text-decoration: none;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s ease, border-color 0.15s ease,
color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
.btn:active { transform: scale(0.97); }
.btn-primary {
background: var(--honey-500);
color: var(--hive-950);
box-shadow: var(--shadow-honey);
}
.btn-primary:hover {
background: var(--honey-400);
box-shadow: 0 0 32px rgba(233, 165, 44, 0.2), 0 0 6px rgba(233, 165, 44, 0.12);
}
.btn-ghost {
background: transparent;
color: var(--hive-100);
border-color: var(--line-strong);
}
.btn-ghost:hover {
border-color: var(--hive-500);
background: rgba(236, 227, 208, 0.04);
}
.btn-small {
padding: 9px 18px;
border-radius: var(--r-sm);
font-size: 13px;
}
/* ── Card ── */
.card {
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: var(--r-lg);
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
border-color: var(--line-strong);
}
/* ── Honeycomb Background ── */
.honeycomb-bg {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='49' viewBox='0 0 28 49'%3E%3Cg fill-rule='evenodd'%3E%3Cg fill='%23e9a52c' fill-opacity='0.03'%3E%3Cpath d='M13.99 9.25l13 7.5v15l-13 7.5L1 31.75v-15l12.99-7.5zM3 17.9v12.7l10.99 6.34 11-6.35V17.9l-11-6.34L3 17.9zM0 15l12.98-7.5V0h-2v6.35L0 12.69v2.3zm0 18.5L12.98 41v8h-2v-6.85L0 35.81v-2.3zM15 0v7.5L27.99 15H28v-2.31h-.01L17 6.35V0h-2zm0 49v-8l12.99-7.5H28v2.31h-.01L17 42.15V49h-2z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
/* ── Animations ── */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
@keyframes honey-pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
@keyframes card-enter {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.float { animation: float 3s ease-in-out infinite; }
.honey-pulse { animation: honey-pulse 3s ease-in-out infinite; }
.card-enter { animation: card-enter 0.6s ease-out both; }
.card-enter-1 { animation-delay: 0.1s; }
.card-enter-2 { animation-delay: 0.2s; }
.card-enter-3 { animation-delay: 0.3s; }
.card-enter-4 { animation-delay: 0.4s; }
/* ── Scroll reveal (used via <Reveal>) ──
Gated behind html.js (set by an inline script in layout.tsx) so content
stays fully visible when JavaScript is unavailable. */
html.js .reveal {
opacity: 0;
transform: translateY(14px);
transition: opacity 0.55s ease, transform 0.55s ease;
}
html.js .reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
.reveal-d1 { transition-delay: 0.06s; }
.reveal-d2 { transition-delay: 0.12s; }
.reveal-d3 { transition-delay: 0.18s; }
.reveal-d4 { transition-delay: 0.24s; }
.reveal-d5 { transition-delay: 0.3s; }
/* ── Card hover lift (legacy — kept for existing components) ── */
.card-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.card-lift:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-honey);
border-color: var(--honey-500) !important;
}
/* ── Button press (legacy — kept for existing components) ── */
.btn-press:active {
transform: scale(0.97);
}
/* ── Reduced motion: disable all non-essential animation ── */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.float,
.honey-pulse,
.card-enter {
animation: none;
}
html.js .reveal {
opacity: 1;
transform: none;
transition: none;
}
.card-lift,
.btn,
.card {
transition: none;
}
}

5
apps/www/app/icon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="14" fill="#0e0c07"/>
<path d="M32 10 L50.6 20.75 L50.6 42.25 L32 53 L13.4 42.25 L13.4 20.75 Z" stroke="#e9a52c" stroke-width="4" stroke-linejoin="round" fill="none"/>
<path d="M32 24 L39.5 28.3 L39.5 36.9 L32 41.2 L24.5 36.9 L24.5 28.3 Z" fill="#f6c45a"/>
</svg>

After

Width:  |  Height:  |  Size: 398 B

246
apps/www/app/layout.tsx Normal file
View File

@@ -0,0 +1,246 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Hanken_Grotesk, JetBrains_Mono } from 'next/font/google';
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getMessages } from 'next-intl/server';
import { ClerkProvider } from '@clerk/nextjs';
import { dark } from '@clerk/themes';
import './globals.css';
/* ──────────────────────────────────────────────────────────────────────────
* Hive DS appearance applied globally to all Clerk components.
* Values updated to the warm-Hive palette (PR8): hex literals reflect the
* remapped scale (e.g. hive-950 = #0e0c07, hive-100 = #ece3d0, honey = #e9a52c).
*
* `baseTheme: dark` flips Clerk's element-level defaults (input borders,
* disabled states, focus rings, hardcoded text shades) to dark-friendly
* baselines. Without it, the `variables` block below only overrides the
* colors that Clerk exposes as variables — anything baked into the
* component CSS stays at light-theme defaults, producing the "dark text
* on dark background" effect on `/sign-in` and `/sign-up`.
*
* Variables layered on top of `dark` paint Hive accent colors:
* - colorPrimary → honey-500 (CTA + active states)
* - colorBackground → hive-950 (page + modal backdrop)
* - colorText → hive-100 (primary fg)
* - colorInputBackground → hive-800 (input fields)
* - colorTextSecondary → hive-300 (secondary fg, helper text)
*
* Inherited by <SignIn>, <SignUp>, <UserProfile>, <SignInButton> modal,
* and <UserButton> popover. Per-component overrides are layered on top
* via `appearance` prop only when needed.
* ────────────────────────────────────────────────────────────────────────── */
// NOTE: do NOT use `as const` here. Clerk's `Appearance` type is a wide
// discriminated union; deeply-readonly literals from `as const` over-narrow
// it and at least one Clerk version silently dropped the `baseTheme` field
// when the prop value didn't match the expected mutable shape.
const HIVE_CLERK_APPEARANCE = {
baseTheme: dark,
variables: {
colorPrimary: '#e9a52c',
colorBackground: '#0e0c07',
colorText: '#ece3d0',
colorTextSecondary: '#c8bfa9',
colorInputBackground: '#1f1a12',
colorInputText: '#ece3d0',
colorNeutral: '#c8bfa9',
borderRadius: '8px',
fontFamily: "'Hanken Grotesk', system-ui, sans-serif",
},
// Belt-and-braces element-level overrides. apps/www does NOT use Tailwind
// (vanilla CSS + custom properties only — see app/globals.css), so these
// are CSSProperties objects, not className strings. Clerk's appearance API
// accepts either form per element.
//
// Each entry targets a Clerk internal element key (stable API, see
// https://clerk.com/docs/customization/appearance-prop). Values match
// Hive DS hex literals so they survive SSR without needing CSS-var
// resolution from a parent.
elements: {
card: {
backgroundColor: '#0e0c07',
border: '1px solid #272117',
boxShadow: '0 4px 16px rgba(0,0,0,0.55), 0 2px 4px rgba(0,0,0,0.4)',
},
headerTitle: { color: '#ece3d0' },
headerSubtitle: { color: '#c8bfa9' },
socialButtonsBlockButton: {
backgroundColor: '#1f1a12',
border: '1px solid #4a4030',
color: '#ece3d0',
},
socialButtonsBlockButtonText: { color: '#ece3d0' },
socialButtonsBlockButtonArrow: { color: '#c8bfa9' },
dividerLine: { backgroundColor: '#4a4030' },
dividerText: { color: '#c8bfa9' },
formFieldLabel: { color: '#d8cfba' },
formFieldInput: {
backgroundColor: '#1f1a12',
border: '1px solid #4a4030',
color: '#ece3d0',
},
formButtonPrimary: {
backgroundColor: '#e9a52c',
color: '#0e0c07',
fontWeight: 600,
},
footerActionText: { color: '#c8bfa9' },
footerActionLink: { color: '#e9a52c' },
identityPreviewText: { color: '#ece3d0' },
identityPreviewEditButton: { color: '#e9a52c' },
// Modal-specific (the `<SignInButton mode="modal">` flow).
modalContent: { backgroundColor: '#0e0c07' },
modalCloseButton: { color: '#c8bfa9' },
},
};
const hanken = Hanken_Grotesk({
subsets: ['latin'],
weight: ['300', '400', '500', '600', '700', '800'],
display: 'swap',
variable: '--font-hanken',
});
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
weight: ['400', '500', '600'],
display: 'swap',
variable: '--font-mono',
});
/* ────────────────────────────────────────────────────────────────── */
/* Metadata constants — duplicated below into both: */
/* 1. The Next.js `metadata` const (for the App Router metadata API) */
/* 2. Explicit JSX inside `<head>` (for guaranteed head placement) */
/* */
/* Why both: in Next.js 15 + React 19 streaming SSR, even SYNC layouts */
/* with a static `metadata` const stream OG/title/meta tags into */
/* `<body>` for client-side hoist (verified at byte 69244 vs head end */
/* at byte 1279 in §3.3 first pass). React hoists them after JS runs, */
/* but Lighthouse SEO + a non-zero share of crawlers read the initial */
/* HTML head. Explicit JSX in `<head>` guarantees the tags ship in */
/* head on the first byte. */
/* */
/* Strings live in module-level const exports (not JSX literals), so */
/* the strict criterion #11 ("no string literal in JSX") is satisfied. */
/* When the second locale lands, `i18n_metadata.ts` will export */
/* per-locale variants and these constants will be replaced by */
/* `getTranslations`-driven values pulled at request time. */
/* ────────────────────────────────────────────────────────────────── */
const META_TITLE = 'Waggle — The AI workspace that remembers';
const META_DESCRIPTION =
'Waggle is a local-first AI workspace with persistent memory. Your projects, decisions, and context compound across every model — Claude, GPT, Gemini, or a local model — and never leave your machine.';
const META_OG_DESCRIPTION =
'A local-first AI workspace with persistent memory. Your context compounds across every model and stays on your machine.';
const META_TWITTER_DESCRIPTION = META_OG_DESCRIPTION;
const META_CANONICAL = 'https://waggle-os.ai/';
const META_OG_IMAGE = 'https://waggle-os.ai/brand/og.png';
export const metadata: Metadata = {
metadataBase: new URL('https://waggle-os.ai/'),
title: META_TITLE,
description: META_DESCRIPTION,
alternates: { canonical: META_CANONICAL },
openGraph: {
title: META_TITLE,
description: META_OG_DESCRIPTION,
url: META_CANONICAL,
type: 'website',
siteName: 'Waggle',
images: [{ url: '/brand/og.png', width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: META_TITLE,
description: META_TWITTER_DESCRIPTION,
images: [META_OG_IMAGE],
},
};
/**
* JSON-LD structured data. Facts only: Solo tier at $0, Team $49/seat/mo
* (packages/shared/src/tiers.ts); Windows + macOS desktop app.
*/
const JSON_LD = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'SoftwareApplication',
name: 'Waggle',
operatingSystem: 'Windows, macOS',
applicationCategory: 'ProductivityApplication',
description: META_DESCRIPTION,
url: META_CANONICAL,
image: META_OG_IMAGE,
offers: [
{ '@type': 'Offer', price: '0', priceCurrency: 'USD', name: 'Solo' },
{ '@type': 'Offer', price: '49', priceCurrency: 'USD', name: 'Team (per seat, monthly)' },
],
publisher: { '@id': 'https://waggle-os.ai/#org' },
},
{
'@type': 'Organization',
'@id': 'https://waggle-os.ai/#org',
name: 'Egzakta Group',
url: 'https://egzakta.com',
},
],
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html
lang="en"
className={`scroll-smooth ${hanken.variable} ${jetbrainsMono.variable}`}
suppressHydrationWarning
>
<head>
{/* Progressive enhancement flag: scroll-reveal styles only apply
when JS runs (html.js gate in globals.css), so no-JS visitors
and crawlers see every section fully rendered. */}
<script
dangerouslySetInnerHTML={{
__html: "document.documentElement.classList.add('js')",
}}
/>
<title>{META_TITLE}</title>
<meta name="description" content={META_DESCRIPTION} />
<link rel="canonical" href={META_CANONICAL} />
<meta property="og:title" content={META_TITLE} />
<meta property="og:description" content={META_OG_DESCRIPTION} />
<meta property="og:url" content={META_CANONICAL} />
<meta property="og:type" content="website" />
<meta property="og:image" content={META_OG_IMAGE} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={META_TITLE} />
<meta name="twitter:description" content={META_TWITTER_DESCRIPTION} />
<meta name="twitter:image" content={META_OG_IMAGE} />
<script
type="application/ld+json"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
// Static compile-time object (no user input); escape `<` per the
// standard JSON-LD embedding guidance to rule out </script> breaks.
__html: JSON.stringify(JSON_LD).replace(/</g, '\\u003c'),
}}
/>
</head>
<body style={{ fontFamily: 'var(--sans)' }}>
<ClerkProvider appearance={HIVE_CLERK_APPEARANCE}>
<IntlWrapper>{children}</IntlWrapper>
</ClerkProvider>
</body>
</html>
);
}
async function IntlWrapper({ children }: { children: ReactNode }) {
const locale = await getLocale();
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages} locale={locale}>
{children}
</NextIntlClientProvider>
);
}

62
apps/www/app/page.tsx Normal file
View File

@@ -0,0 +1,62 @@
import { getTranslations } from 'next-intl/server';
import Navbar from './_components/Navbar';
import Hero from './_components/Hero';
import ProblemTurn from './_components/ProblemTurn';
import HowItWorks from './_components/HowItWorks';
import MemoryDiagram from './_components/MemoryDiagram';
import ProofBand from './_components/ProofBand';
import FeatureGrid from './_components/FeatureGrid';
import SovereigntyBand from './_components/SovereigntyBand';
import BrandPersonasCard from './_components/BrandPersonasCard';
import OpenSource from './_components/OpenSource';
import Pricing from './_components/Pricing';
import FinalCTA from './_components/FinalCTA';
import Footer from './_components/Footer';
/**
* Waggle landing page — 2026-07 rebuild.
*
* Narrative spine: promise (hero) → problem → how it works → what's
* actually different (memory) → proof (LoCoMo) → breadth (features) →
* trust (sovereignty) → brand moment (personas) → open source → pricing →
* close. Every section reads its copy from `messages/en.json`; every
* factual claim traces to the repo (see docs/superpowers/specs/
* 2026-07-03-www-marketing-site-design.md §1).
*/
export default async function HomePage() {
const t = await getTranslations('landing.personas_section');
const a11y = await getTranslations('landing.a11y');
return (
<>
<a href="#main" className="skip-link">
{a11y('skip')}
</a>
<Navbar />
<main id="main">
<Hero />
<ProblemTurn />
<HowItWorks />
<MemoryDiagram />
<ProofBand />
<FeatureGrid />
<SovereigntyBand />
<section
id="personas"
style={{ background: 'var(--hive-950, #0e0c07)' }}
aria-labelledby="waggle-hive-heading"
>
<BrandPersonasCard
eyebrow={t('eyebrow')}
heading={t('heading')}
subtitle={t('subtitle')}
/>
</section>
<OpenSource />
<Pricing />
<FinalCTA />
</main>
<Footer />
</>
);
}

19
apps/www/app/robots.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { MetadataRoute } from 'next';
/**
* Robots policy: index the marketing surface, keep auth/billing/internal
* design-QA routes out of the index. Served at /robots.txt by the App
* Router metadata convention.
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/account', '/sign-in', '/sign-up', '/design/', '/api/'],
},
],
sitemap: 'https://waggle-os.ai/sitemap.xml',
};
}

View File

@@ -0,0 +1,32 @@
import { SignIn } from '@clerk/nextjs';
import type { CSSProperties } from 'react';
/**
* Hosted sign-in fallback page (Sesija E §5.2).
*
* Reached when:
* - User lands on /sign-in directly (e.g. shared link).
* - <SignInButton mode="modal"> redirects (modal failure or full-flow opt-in).
* - /account redirects an unauthenticated user here.
*
* Catch-all `[[...sign-in]]` segment lets Clerk handle multi-step flows
* (verification, social-OAuth callback) without explicit route definitions.
*
* Appearance is inherited from <ClerkProvider> in app/layout.tsx (Hive DS).
*/
export default function SignInPage() {
return (
<main style={pageStyle}>
<SignIn />
</main>
);
}
const pageStyle: CSSProperties = {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '96px 24px 48px',
background: 'var(--hive-950, #0e0c07)',
};

View File

@@ -0,0 +1,32 @@
import { SignUp } from '@clerk/nextjs';
import type { CSSProperties } from 'react';
/**
* Hosted sign-up fallback page (Sesija E §5.2).
*
* Reached when:
* - User lands on /sign-up directly.
* - Pricing CTAs (§5.4) redirect signed-out users here with
* `forceRedirectUrl=/api/stripe/checkout?tier=...`.
*
* Catch-all `[[...sign-up]]` segment supports multi-step flows
* (email verification, social-OAuth callback).
*
* Appearance is inherited from <ClerkProvider> in app/layout.tsx (Hive DS).
*/
export default function SignUpPage() {
return (
<main style={pageStyle}>
<SignUp />
</main>
);
}
const pageStyle: CSSProperties = {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '96px 24px 48px',
background: 'var(--hive-950, #0e0c07)',
};

30
apps/www/app/sitemap.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { MetadataRoute } from 'next';
const BASE_URL = 'https://waggle-os.ai';
/**
* Sitemap for crawler discovery. Served at `/sitemap.xml` automatically
* by Next.js App Router from this metadata route convention.
*
* Includes the homepage + the public-facing methodology docs page (Day 0
* Trust Band Card 4 link target per Path D landing decoupling). The
* `/design/personas` route is intentionally omitted — it's robots-blocked
* via metadata and discovery is URL-only per amendment §1.4.
*/
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
return [
{
url: `${BASE_URL}/`,
lastModified,
changeFrequency: 'weekly',
priority: 1.0,
},
{
url: `${BASE_URL}/docs/methodology`,
lastModified,
changeFrequency: 'monthly',
priority: 0.7,
},
];
}

15
apps/www/i18n/request.ts Normal file
View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from 'next-intl/server';
/**
* next-intl request config.
*
* v1 ships English-only — single locale, no `/[locale]` routing. Locale is
* hardcoded to `en`; messages load from `../messages/en.json`. Adding more
* locales later is a config-only change (set up middleware + match
* `[locale]` segment in app/).
*/
export default getRequestConfig(async () => {
const locale = 'en';
const messages = (await import(`../messages/${locale}.json`)).default;
return { locale, messages };
});

316
apps/www/messages/en.json Normal file
View File

@@ -0,0 +1,316 @@
{
"landing": {
"a11y": {
"skip": "Skip to content"
},
"metadata": {
"title": "Waggle — The AI workspace that remembers",
"description": "Waggle is a local-first AI workspace with persistent memory. Your projects, decisions, and context compound across every model — Claude, GPT, Gemini, or a local model — and never leave your machine.",
"og_title": "Waggle — The AI workspace that remembers",
"og_description": "A local-first AI workspace with persistent memory. Your context compounds across every model and stays on your machine.",
"twitter_title": "Waggle — The AI workspace that remembers",
"twitter_description": "A local-first AI workspace with persistent memory. Your context compounds across every model and stays on your machine."
},
"navbar": {
"links": {
"how_it_works": "How it works",
"memory": "Memory",
"benchmark": "Benchmark",
"open_source": "Open source",
"pricing": "Pricing"
},
"ctas": {
"sign_in": "Sign in",
"download": "Download"
},
"aria": {
"home": "Waggle home",
"primary": "Primary",
"toggle_menu": "Toggle menu"
}
},
"hero": {
"eyebrow": "Local-first · model-agnostic · open substrate",
"headline_lead": "The AI workspace that",
"headline_emphasis": "remembers",
"subhead": "Waggle keeps a real memory of you and your work — on your machine, under your control. Every session starts where the last one ended, whichever model you run.",
"cta_secondary": "Read the benchmark",
"microcopy_free": "Solo — free forever",
"microcopy_platforms": "Windows & macOS",
"microcopy_oss": "Apache-2.0 memory substrate",
"proof_stat": "86.49%",
"proof_strip": "on LoCoMo — state-of-the-art long-term memory, on the previous leader's own protocol"
},
"hero_visual": {
"aria_label": "Diagram of Waggle's local memory serving four AI models",
"window_title": "~/.waggle/hive",
"window_badge": "local · sqlite",
"center_label": "your memory",
"center_sublabel": "frames · graph",
"chips": {
"claude_primary": "claude",
"claude_sub": "recall",
"gpt_primary": "gpt",
"gpt_sub": "recall",
"qwen_primary": "qwen · local",
"qwen_sub": "commit",
"gemini_primary": "gemini",
"gemini_sub": "recall"
},
"footer_left": "hybrid search: vector + keyword",
"footer_right": "nothing leaves without you"
},
"problem": {
"eyebrow": "The problem",
"headline": "Every AI session starts from zero.",
"beats": {
"reintroduce": {
"title": "You re-introduce yourself",
"body": "The client, the voice, the constraints, the stack — explained again, to the same tool, for the hundredth time."
},
"tabs": {
"title": "Context dies in tabs",
"body": "The decision lives in one chat, the draft in another, the reasoning in neither. Nothing connects."
},
"compound": {
"title": "Nothing compounds",
"body": "A year of AI conversations — and your assistant knows you no better than it did on day one."
}
},
"turn": "Waggle takes the opposite bet: your context is an asset. Capture it once, own it locally, and let every session build on the last."
},
"how_it_works": {
"eyebrow": "How it works",
"headline": "Three steps to an AI that knows your work.",
"step_01": {
"number": "01",
"title": "Bring your history",
"body": "Import your ChatGPT, Claude, and Gemini conversations — plus PDFs, notes, and documents. Harvest turns them into structured memory, entirely on your machine."
},
"step_02": {
"number": "02",
"title": "Work in workspaces",
"body": "Chat, tasks, and files in one place, with specialist personas for the way you work — and any model underneath: Claude, GPT, Gemini, or a local model."
},
"step_03": {
"number": "03",
"title": "Let it compound",
"body": "Every session updates a knowledge graph of your projects, people, and decisions. Tomorrow starts where today ended — nothing re-explained, nothing lost."
}
},
"memory": {
"eyebrow": "Under the hood",
"headline": "A real memory substrate, not a longer prompt.",
"body": "Conversations and files become frames in a local SQLite store. Frames index into hybrid search — vector and keyword — and a knowledge graph links the people, projects, and decisions inside them. Identity and awareness layers track who you are and what you're working on, so recall is precise, explainable, and fast.",
"diagram": {
"input": "Your conversations & files",
"frames": "Frames",
"search": "Hybrid search",
"search_sub": "vector + keyword",
"graph": "Knowledge graph",
"graph_sub": "people · projects · decisions",
"output": "Any model"
},
"chips": {
"local": "SQLite on your device",
"provenance": "Provenance on every memory",
"erasure": "Erase actually erases"
}
},
"proof": {
"eyebrow": "Proof",
"headline": "State of the art on LoCoMo — on the previous leader's own ruler.",
"body": "LoCoMo is the standard benchmark for long-term conversational memory. Waggle's open-source memory substrate scores 86.49% — measured under the previous leader's exact protocol and judge, and ahead in or tied on every question category. The full harness ships in the repo: run it yourself, offline.",
"footnote": "N=1,540 · GPT-4.1-mini as answerer and judge (the prior leader's published protocol) · +4.54 points, z=4.64 · reproducible offline",
"cta_methodology": "Read the methodology",
"cta_reproduce": "Reproduce it on GitHub",
"chart_aria": "Bar chart comparing LoCoMo scores: Waggle 86.49%, Memori 81.95%, Mem0 73.96%"
},
"features": {
"eyebrow": "The workspace",
"headline": "A complete workspace, not another chat window.",
"items": {
"personas": {
"title": "22 specialist personas",
"body": "From planner and verifier to analyst and coder — each with its own tools, guardrails, and a hard boundary on what it won't do."
},
"models": {
"title": "Any model underneath",
"body": "Thirteen provider families under one router — Claude, GPT, Gemini, Qwen, Grok, DeepSeek, Mistral, and more, with your own keys. Switch mid-project; the memory stays."
},
"harvest": {
"title": "Harvest your history",
"body": "Import ChatGPT, Claude, Gemini, and Perplexity exports — plus PDF, Markdown, and URLs — into structured, searchable memory."
},
"loops": {
"title": "Loops & approvals",
"body": "Recurring agent runs that report before they act. Anything consequential waits in an approval queue for your sign-off."
},
"skills": {
"title": "Skills & connectors",
"body": "Installable skills with integrity auditing, connectors to your tools, and a built-in catalog of 149 MCP servers."
},
"memory_center": {
"title": "Memory Center",
"body": "See the original source behind every memory, trace where an answer came from, and erase for real — erasure survives re-import."
}
}
},
"sovereignty": {
"eyebrow": "Your data",
"headline": "Local-first isn't a feature flag. It's the architecture.",
"items": {
"device": {
"title": "On-device by default",
"body": "Your memory is a SQLite file on your machine — not a row in someone else's cloud."
},
"egress": {
"title": "Only what you send leaves",
"body": "Model calls go to the providers you configure, with your keys, held in a local vault."
},
"erasure": {
"title": "Erasure that holds",
"body": "Delete a source and it stays deleted — with provenance and an audit trail, built for the EU AI Act era."
},
"injection": {
"title": "Screened inputs",
"body": "External content is scanned for prompt injection before any agent acts on it."
}
},
"cta": "Read our EU AI Act statement"
},
"personas_section": {
"eyebrow": "Personas",
"heading": "Meet the hive.",
"subtitle": "22 personas ship in the box — universal modes like planner, verifier, and researcher, plus workspace specialists from sales to legal. Each one knows its tools, and its limits."
},
"open_source": {
"eyebrow": "Open source",
"headline": "The memory layer is open. Take it.",
"body": "hive-mind is Waggle's memory substrate — frames, hybrid search, knowledge graph — published with the full LoCoMo benchmark harness. Use it in your own agents, audit every query, or reproduce our numbers offline.",
"cta_github": "View on GitHub",
"cta_npm": "Install from npm",
"terminal_title": "reproduce the benchmark",
"terminal_aria": "Terminal commands for reproducing the LoCoMo benchmark"
},
"pricing": {
"eyebrow": "Pricing",
"headline": "Memory is free forever. Pay when you scale.",
"subhead": "Every install starts with 15 days of everything unlocked — no credit card. After that, the free tier keeps your memory, import, and agents for good.",
"toggle": {
"monthly": "Monthly",
"annual": "Annual",
"save_pill": "2 months free",
"aria_group": "Billing period"
},
"popular_badge": "Most popular",
"loading": "Loading…",
"notices": {
"cancelled": "Checkout was cancelled. Your plan was not changed.",
"retry": "Try Team checkout again"
},
"tiers": {
"solo": {
"name": "Solo",
"price_monthly": "$0",
"price_annual": "$0",
"note": "Free, forever",
"tagline": "Everything you need to start compounding.",
"bullet_memory": "Full memory + Harvest import",
"bullet_workspaces": "Unlimited workspaces & agents",
"bullet_marketplace": "Skills marketplace & all connectors",
"bullet_models": "Any model & embeddings, your keys",
"bullet_skills": "Custom skills, PDF & JSON export"
},
"teams": {
"name": "Team",
"price_monthly": "$49/seat/month",
"price_annual": "$490/seat/year",
"note": "2 months free on annual",
"tagline": "Shared memory without giving up custody.",
"cta": "Get Team",
"bullet_everything_solo": "Everything in Solo",
"bullet_shared": "Shared workspaces",
"bullet_dance": "WaggleDance multi-agent coordination",
"bullet_governance": "Governance & audit controls"
}
},
"enterprise": {
"name": "Enterprise",
"tagline": "Sovereign, on your own infrastructure.",
"price": "Custom",
"note": "Consultative — KVARK",
"text": "KVARK runs everything Waggle does inside your perimeter — your permissions, full audit trail, complete governance. Your data never leaves your infrastructure.",
"cta": "Talk to KVARK"
},
"errors": {
"checkout_default": "Checkout configuration required. Please try again later.",
"network": "Could not connect to server. Please try again later."
}
},
"final_cta": {
"headline": "Start remembering.",
"subhead": "Download Waggle, import your history, and give your AI a memory that's yours.",
"cta_github": "View on GitHub",
"kvark_text": "Need it on your organization's sovereign infrastructure?",
"kvark_cta": "Talk to the KVARK team"
},
"footer": {
"brand": {
"description": "The local-first AI workspace with persistent memory. Built on the open hive-mind substrate.",
"attribution": "A product of Egzakta Group"
},
"columns": {
"product": {
"title": "Product",
"links": {
"download": "Download",
"pricing": "Pricing",
"how_it_works": "How it works",
"memory": "Memory"
}
},
"research": {
"title": "Research",
"links": {
"methodology": "Methodology",
"benchmarks": "LoCoMo results",
"hive_mind": "hive-mind on GitHub"
}
},
"company": {
"title": "Company",
"links": {
"about_egzakta": "About Egzakta",
"kvark": "KVARK — sovereign AI",
"contact": "Contact"
}
},
"legal": {
"title": "Legal",
"links": {
"terms": "Terms",
"privacy": "Privacy",
"cookies": "Cookies",
"eu_ai_act": "EU AI Act statement",
"apache": "Apache-2.0 license"
}
}
},
"base_line": {
"left": "© 2026 Egzakta Advisory · Waggle is a product of Egzakta Group",
"right": "waggle-os.ai · local-first · open substrate"
}
},
"download_cta": {
"default": "Download",
"with_os": "Download for {os}"
},
"brand_personas": {
"default_heading": "The Waggle Hive",
"default_subtitle": "Thirteen personas for the work your AI does while you sleep.",
"compact_aria": "Waggle persona grid (compact variant)",
"tile_aria_template": "Waggle {title} bee mascot"
}
}
}

28
apps/www/middleware.ts Normal file
View File

@@ -0,0 +1,28 @@
// Next.js 15.5.15 convention: middleware.ts
// Per Clerk skill version note + Next.js ≤15 docs, middleware.ts is the runtime-supported filename.
// Future Next.js 16+ canary will support proxy.ts as successor naming convention.
// Re-verify framework convention before any Next.js major version upgrade.
import { clerkMiddleware } from '@clerk/nextjs/server';
/**
* Clerk auth middleware.
*
* Default behavior: all routes are PUBLIC. Per-route protection is enforced
* with `await auth.protect()` inside Server Components / Route Handlers.
* Sign-up gating for Pro/Teams checkout is handled at CTA level via
* <SignUpButton mode="modal"> (see §5.4).
*/
export default clerkMiddleware();
export const config = {
matcher: [
// Keep public acquisition, legal, documentation, and download pages
// independent of Clerk session refreshes. Auth is required only where the
// route reads identity or owns an authenticated flow.
'/account(.*)',
'/sign-in(.*)',
'/sign-up(.*)',
'/(api|trpc)(.*)',
],
};

6
apps/www/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

15
apps/www/next.config.mjs Normal file
View File

@@ -0,0 +1,15 @@
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// Match the existing `apps/web` philosophy: build outputs are tree-shakeable
// ESM, modern image formats served via Next.js's built-in optimizer.
images: {
formats: ['image/avif', 'image/webp'],
},
};
export default withNextIntl(nextConfig);

34
apps/www/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "waggle-www",
"private": true,
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "node --disable-warning=DEP0040 ../../node_modules/vitest/vitest.mjs run",
"test:watch": "vitest"
},
"dependencies": {
"@clerk/nextjs": "^7.3.0",
"@clerk/themes": "^2.4.57",
"lucide-react": "^0.577.0",
"next": "^15.1.0",
"next-intl": "^3.26.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"stripe": "^21.0.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.1",
"eslint-config-next": "^15.1.0",
"typescript": "^5.9.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

31
apps/www/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"allowJs": true,
"noEmit": true,
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"app/**/*.ts",
"app/**/*.tsx",
"__tests__/**/*.ts",
"__tests__/**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules", ".next"]
}

45
apps/www/vitest.config.ts Normal file
View File

@@ -0,0 +1,45 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
// Next.js pulls Node's deprecated built-in `punycode` module through a
// dependency chain during Vitest worker startup. This is an upstream warning,
// not a www failure; propagate the narrow suppression to child workers so the
// release signal remains readable.
if (!process.env.NODE_OPTIONS?.includes('--disable-warning=DEP0040')) {
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --disable-warning=DEP0040`.trim();
}
/**
* Vitest config — independent of the Next.js bundler. Vitest uses Vite under
* the hood for transformation, so `@vitejs/plugin-react` stays in devDeps
* even though the app build is now driven by Next.js.
*
* `esbuild.jsx: 'automatic'` is required because tsconfig.json sets
* `"jsx": "preserve"` (Next.js's required value — it transforms JSX itself
* with SWC). Without an explicit JSX runtime here, vitest's underlying
* esbuild transformer falls back to legacy `React.createElement()` output
* and tests fail with `ReferenceError: React is not defined`.
*
* The `@/*` alias mirrors the `tsconfig.json` `paths` entry so test imports
* resolve identically to runtime imports (e.g.
* `@/src/components/BrandPersonasCard`).
*/
export default defineConfig({
plugins: [react()],
esbuild: {
jsx: 'automatic',
jsxImportSource: 'react',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./__tests__/setup.ts'],
include: ['__tests__/**/*.{test,spec}.{ts,tsx}'],
},
});