11
apps/www/.env.example
Normal 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
|
||||
51
apps/www/.env.local.example
Normal 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
|
||||
BIN
apps/www/.screenshots/docs-methodology.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/www/.screenshots/variant-a-marcus-default.png
Normal file
|
After Width: | Height: | Size: 988 KiB |
BIN
apps/www/.screenshots/variant-b-klaudia-compliance.png
Normal file
|
After Width: | Height: | Size: 984 KiB |
BIN
apps/www/.screenshots/variant-e-petra-legal-tech.png
Normal file
|
After Width: | Height: | Size: 967 KiB |
69
apps/www/LIGHTHOUSE.md
Normal 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.
|
||||
119
apps/www/SESIJA-D-MANIFEST.md
Normal 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.
|
||||
188
apps/www/SESIJA-E-MANIFEST.md
Normal 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.**
|
||||
153
apps/www/__tests__/BrandPersonasCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
51
apps/www/__tests__/Pricing.test.tsx
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
25
apps/www/__tests__/deployment-workflow.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
58
apps/www/__tests__/download-path.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
39
apps/www/__tests__/layout.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
35
apps/www/__tests__/legal-copy.test.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
13
apps/www/__tests__/middleware.test.ts
Normal 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)(.*)',
|
||||
]);
|
||||
});
|
||||
});
|
||||
45
apps/www/__tests__/setup.ts
Normal 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,
|
||||
}));
|
||||
93
apps/www/__tests__/stripe-checkout-route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
138
apps/www/app/(legal)/cookies/page.tsx
Normal 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 →
|
||||
“Allow anonymous product analytics.” 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,
|
||||
};
|
||||
208
apps/www/app/(legal)/eu-ai-act/page.tsx
Normal 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. (“we”) publishes this statement to
|
||||
describe how Waggle OS relates to Regulation (EU) 2024/1689
|
||||
(“EU AI Act”). Our intent is to be a transparent
|
||||
participant in the AI value chain, even as the EU AI Act’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
|
||||
“deployer of AI systems”) 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’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’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’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',
|
||||
};
|
||||
29
apps/www/app/(legal)/layout.tsx
Normal 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)',
|
||||
};
|
||||
208
apps/www/app/(legal)/privacy/page.tsx
Normal 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. (“Egzakta”,
|
||||
“we”, “us”), 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’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’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’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',
|
||||
};
|
||||
198
apps/www/app/(legal)/terms/page.tsx
Normal 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 (“Terms”) govern your use of Waggle
|
||||
OS (the “Service”), provided by Egzakta Group d.o.o.
|
||||
(“Egzakta”, “we”, “us”). 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’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 “as is” 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’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',
|
||||
};
|
||||
58
apps/www/app/_components/BrandMark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
537
apps/www/app/_components/BrandPersonasCard.tsx
Normal 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;
|
||||
}
|
||||
`;
|
||||
71
apps/www/app/_components/DownloadCTA.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
apps/www/app/_components/FeatureGrid.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
127
apps/www/app/_components/FeatureGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
71
apps/www/app/_components/FinalCTA.module.css
Normal 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);
|
||||
}
|
||||
51
apps/www/app/_components/FinalCTA.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/www/app/_components/Footer.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
105
apps/www/app/_components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
apps/www/app/_components/Hero.module.css
Normal 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);
|
||||
}
|
||||
71
apps/www/app/_components/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
apps/www/app/_components/HeroVisual.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
160
apps/www/app/_components/HeroVisual.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
apps/www/app/_components/HowItWorks.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
44
apps/www/app/_components/HowItWorks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
apps/www/app/_components/MemoryDiagram.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
80
apps/www/app/_components/MemoryDiagram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
apps/www/app/_components/Navbar.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
153
apps/www/app/_components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/www/app/_components/OpenSource.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
88
apps/www/app/_components/OpenSource.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
apps/www/app/_components/Pricing.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
253
apps/www/app/_components/Pricing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/www/app/_components/ProblemTurn.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
apps/www/app/_components/ProblemTurn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
apps/www/app/_components/ProofBand.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
84
apps/www/app/_components/ProofBand.tsx
Normal 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 0–100 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>
|
||||
);
|
||||
}
|
||||
86
apps/www/app/_components/Reveal.tsx
Normal 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 1–5 → 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>
|
||||
);
|
||||
}
|
||||
47
apps/www/app/_components/SovereigntyBand.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
46
apps/www/app/_components/SovereigntyBand.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
apps/www/app/_data/personas.ts
Normal 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';
|
||||
48
apps/www/app/_data/proof-points.ts
Normal 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;
|
||||
24
apps/www/app/_lib/event-taxonomy.ts
Normal 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 ?? {});
|
||||
}
|
||||
15
apps/www/app/_lib/os-detection.ts
Normal 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;
|
||||
}
|
||||
39
apps/www/app/account/page.tsx
Normal 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)',
|
||||
};
|
||||
284
apps/www/app/api/stripe/checkout/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
224
apps/www/app/api/webhooks/stripe/route.ts
Normal 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 });
|
||||
}
|
||||
38
apps/www/app/design/personas/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
apps/www/app/design/personas/preview-1024-placeholder.png
Normal file
|
After Width: | Height: | Size: 791 KiB |
BIN
apps/www/app/design/personas/preview-1024.png
Normal file
|
After Width: | Height: | Size: 769 KiB |
253
apps/www/app/docs/methodology/page.tsx
Normal 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);
|
||||
}
|
||||
`;
|
||||
115
apps/www/app/download/page.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
};
|
||||
}
|
||||
32
apps/www/app/sign-in/[[...sign-in]]/page.tsx
Normal 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)',
|
||||
};
|
||||
32
apps/www/app/sign-up/[[...sign-up]]/page.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
BIN
apps/www/public/brand/bee-analyst-dark.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
apps/www/public/brand/bee-architect-dark.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/www/public/brand/bee-builder-dark.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/www/public/brand/bee-celebrating-dark.png
Normal file
|
After Width: | Height: | Size: 959 KiB |
BIN
apps/www/public/brand/bee-confused-dark.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
apps/www/public/brand/bee-connector-dark.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
apps/www/public/brand/bee-hunter-dark.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
apps/www/public/brand/bee-marketer-dark.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/www/public/brand/bee-orchestrator-dark.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/www/public/brand/bee-researcher-dark.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/www/public/brand/bee-sleeping-dark.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
apps/www/public/brand/bee-team-dark.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
apps/www/public/brand/bee-writer-dark.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/www/public/brand/hex-texture-dark.png
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
apps/www/public/brand/logo-light.jpeg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
apps/www/public/brand/logo.jpeg
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
apps/www/public/brand/og.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
31
apps/www/tsconfig.json
Normal 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
@@ -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}'],
|
||||
},
|
||||
});
|
||||