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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

View File

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

View File

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

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

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

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

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

After

Width:  |  Height:  |  Size: 398 B

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

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

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

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

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

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

View File

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

View File

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

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

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