This commit is contained in:
138
apps/www/app/(legal)/cookies/page.tsx
Normal file
138
apps/www/app/(legal)/cookies/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cookie Policy — Waggle',
|
||||
description:
|
||||
'Cookie policy for Waggle OS by Egzakta Group d.o.o.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function CookiesPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 style={h1Style}>Cookie Policy</h1>
|
||||
<p style={metaLineStyle}>
|
||||
<strong>Effective date:</strong> July 8, 2026 · <strong>Last updated:</strong> July 8, 2026
|
||||
</p>
|
||||
<p style={paragraphStyle}>
|
||||
This Cookie Policy explains how Waggle OS uses cookies and similar
|
||||
technologies on its web properties (the marketing site at
|
||||
waggle-os.ai and any in-product web views).
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>1. What cookies are</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Cookies are small text files stored by your browser when you visit a
|
||||
website. They allow the site to remember information about your
|
||||
visit (e.g., your login state). Similar technologies include local
|
||||
storage, session storage, and pixels.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>2. Cookies we use</h2>
|
||||
|
||||
<h3 style={h3Style}>Strictly necessary</h3>
|
||||
<p style={paragraphStyle}>
|
||||
These cookies are required for the Service to function and cannot be
|
||||
disabled.
|
||||
</p>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
<strong>Clerk session cookies</strong> — keep you logged in. Set by{' '}
|
||||
<code>clerk.waggle-os.ai</code>. Cleared on logout.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
<strong>CSRF protection</strong> — prevents cross-site request
|
||||
forgery. Cleared on tab close.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={h3Style}>Analytics (opt-in)</h3>
|
||||
<p style={paragraphStyle}>
|
||||
These cookies are loaded only if you opt in via Settings → Privacy →
|
||||
“Allow anonymous product analytics.” Default is opt-out.
|
||||
</p>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
<strong>PostHog cookies</strong> (<code>ph_*</code>) — capture
|
||||
anonymous usage events for product improvement. We do not use
|
||||
PostHog session recordings, do not capture form inputs, and do not
|
||||
link analytics to your account email.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 style={h2Style}>3. No advertising or social-media tracking</h2>
|
||||
<p style={paragraphStyle}>
|
||||
We do not use third-party advertising networks, social-media pixels
|
||||
(Meta, TikTok, X), or cross-site tracking. We do not sell or share
|
||||
data with advertisers.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>4. Managing cookies</h2>
|
||||
<p style={paragraphStyle}>
|
||||
You can clear cookies through your browser settings. For granular
|
||||
control of the analytics cookie, use Settings → Privacy in the
|
||||
Waggle product.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>5. Changes to this policy</h2>
|
||||
<p style={paragraphStyle}>
|
||||
We may update this Cookie Policy. Material changes will be announced
|
||||
via the product and email.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>6. Contact</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Email: <strong>privacy@egzakta.com</strong>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const h1Style: CSSProperties = {
|
||||
fontSize: 'clamp(28px, 4vw, 36px)',
|
||||
fontWeight: 700,
|
||||
marginBottom: 24,
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
};
|
||||
|
||||
const h2Style: CSSProperties = {
|
||||
fontSize: 'clamp(18px, 2.4vw, 22px)',
|
||||
fontWeight: 600,
|
||||
marginTop: 32,
|
||||
marginBottom: 12,
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
};
|
||||
|
||||
const h3Style: CSSProperties = {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
color: 'var(--hive-100, #d8cfba)',
|
||||
};
|
||||
|
||||
const metaLineStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: 'var(--hive-300, #948a73)',
|
||||
marginBottom: 24,
|
||||
};
|
||||
|
||||
const paragraphStyle: CSSProperties = {
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
color: 'var(--hive-200, #d8cfba)',
|
||||
marginBottom: 16,
|
||||
};
|
||||
|
||||
const listStyle: CSSProperties = {
|
||||
paddingLeft: 24,
|
||||
marginBottom: 16,
|
||||
color: 'var(--hive-200, #d8cfba)',
|
||||
};
|
||||
|
||||
const listItemStyle: CSSProperties = {
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 8,
|
||||
};
|
||||
208
apps/www/app/(legal)/eu-ai-act/page.tsx
Normal file
208
apps/www/app/(legal)/eu-ai-act/page.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'EU AI Act Statement — Waggle',
|
||||
description:
|
||||
'EU AI Act compliance statement for Waggle OS by Egzakta Group d.o.o.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function EuAiActPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 style={h1Style}>EU AI Act Statement</h1>
|
||||
<p style={metaLineStyle}>
|
||||
<strong>Effective date:</strong> July 8, 2026 · <strong>Last updated:</strong> July 8, 2026
|
||||
</p>
|
||||
<p style={paragraphStyle}>
|
||||
Egzakta Group d.o.o. (“we”) publishes this statement to
|
||||
describe how Waggle OS relates to Regulation (EU) 2024/1689
|
||||
(“EU AI Act”). Our intent is to be a transparent
|
||||
participant in the AI value chain, even as the EU AI Act’s
|
||||
provisions phase in through 2026.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>1. Our role in the AI value chain</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Waggle OS is a <strong>deployer</strong> (sometimes called
|
||||
“deployer of AI systems”) under EU AI Act terminology
|
||||
when end-users install the Waggle desktop application and use it
|
||||
for their own purposes. Egzakta is <strong>not</strong> a provider
|
||||
of general-purpose AI models (GPAI providers — defined in Article
|
||||
51). Waggle OS routes user requests to third-party AI providers
|
||||
(Anthropic, OpenAI, Mistral, and others). The underlying GPAI
|
||||
providers fulfill the obligations applicable to them.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>2. Article 50 — transparency obligations</h2>
|
||||
<p style={paragraphStyle}>
|
||||
We comply with the transparency obligations of Article 50 by:
|
||||
</p>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
<strong>Clearly labeling AI-generated output</strong>: the Waggle
|
||||
UI marks AI-generated responses as such; the user always knows
|
||||
they are interacting with an AI system.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
<strong>Disclosing system prompts on request</strong>: users can
|
||||
view the active persona’s system prompt in Settings →
|
||||
Personas → selected persona → System Prompt.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
<strong>No deepfake generation</strong>: Waggle OS does not
|
||||
generate synthetic images, audio, or video that could be confused
|
||||
with authentic media.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 style={h2Style}>3. Article 5 — prohibited practices</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Waggle OS does not implement, encourage, or facilitate any
|
||||
prohibited AI practice under Article 5, including:
|
||||
</p>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
Subliminal manipulation or exploitation of vulnerabilities.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Social scoring of natural persons.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Real-time remote biometric identification in publicly accessible
|
||||
spaces.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Emotion inference in workplace or education contexts.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 style={h2Style}>4. High-risk AI systems</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Waggle OS in its default consumer configuration is not a high-risk
|
||||
AI system under Annex III. If users deploy Waggle OS for high-risk
|
||||
purposes (e.g., employment screening, credit scoring), the deployer
|
||||
is responsible for fulfilling the obligations applicable to that
|
||||
high-risk system.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>5. General-purpose AI (GPAI) considerations</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Waggle OS depends on GPAI models provided by third parties. Those
|
||||
providers publish their own EU AI Act compliance information:
|
||||
</p>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
Anthropic:{' '}
|
||||
<a href="https://www.anthropic.com/eu-ai-act" style={linkStyle}>
|
||||
anthropic.com/eu-ai-act
|
||||
</a>
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
OpenAI:{' '}
|
||||
<a href="https://openai.com/eu-ai-act" style={linkStyle}>
|
||||
openai.com/eu-ai-act
|
||||
</a>
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Mistral:{' '}
|
||||
<a href="https://mistral.ai/eu-ai-act" style={linkStyle}>
|
||||
mistral.ai/eu-ai-act
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 style={h2Style}>6. Risk management and documentation</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Egzakta maintains internal documentation of:
|
||||
</p>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
Models routed to and their provenance.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Safety testing for the persona system and behavioral spec.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
The evolution subsystem (see arxiv paper, pending submission).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
User-facing transparency mechanisms.
|
||||
</li>
|
||||
</ul>
|
||||
<p style={paragraphStyle}>
|
||||
We will publish a public AI risk management summary by Article
|
||||
50’s effective date (2026-08-02 for Article 50 obligations).
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>7. Data protection alignment</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Personal data handling is governed by our{' '}
|
||||
<a href="/privacy" style={linkStyle}>
|
||||
Privacy Policy
|
||||
</a>{' '}
|
||||
and the GDPR. We do not train AI models on user data without
|
||||
explicit consent. Local memory data never leaves the user’s
|
||||
device unless they explicitly enable team sharing (Teams tier).
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>
|
||||
8. Contact for data subject and EU AI Act inquiries
|
||||
</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Email: <strong>ai-compliance@egzakta.com</strong>
|
||||
<br />
|
||||
Data Protection Officer: <strong>dpo@egzakta.com</strong>
|
||||
<br />
|
||||
Representative inquiries under Article 25:{' '}
|
||||
<strong>ai-compliance@egzakta.com</strong>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const h1Style: CSSProperties = {
|
||||
fontSize: 'clamp(28px, 4vw, 36px)',
|
||||
fontWeight: 700,
|
||||
marginBottom: 24,
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
};
|
||||
|
||||
const h2Style: CSSProperties = {
|
||||
fontSize: 'clamp(18px, 2.4vw, 22px)',
|
||||
fontWeight: 600,
|
||||
marginTop: 32,
|
||||
marginBottom: 12,
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
};
|
||||
|
||||
const metaLineStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: 'var(--hive-300, #948a73)',
|
||||
marginBottom: 24,
|
||||
};
|
||||
|
||||
const paragraphStyle: CSSProperties = {
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
color: 'var(--hive-200, #d8cfba)',
|
||||
marginBottom: 16,
|
||||
};
|
||||
|
||||
const listStyle: CSSProperties = {
|
||||
paddingLeft: 24,
|
||||
marginBottom: 16,
|
||||
color: 'var(--hive-200, #d8cfba)',
|
||||
};
|
||||
|
||||
const listItemStyle: CSSProperties = {
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
const linkStyle: CSSProperties = {
|
||||
color: 'var(--honey-400, #f6c45a)',
|
||||
textDecoration: 'underline',
|
||||
};
|
||||
29
apps/www/app/(legal)/layout.tsx
Normal file
29
apps/www/app/(legal)/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import Navbar from '../_components/Navbar';
|
||||
import Footer from '../_components/Footer';
|
||||
|
||||
/**
|
||||
* Layout for the legal route group: /privacy, /terms, /cookies, /eu-ai-act.
|
||||
*
|
||||
* Wraps each legal page with the same Navbar + Footer chrome as the landing
|
||||
* page so the legal pages don't feel like a separate microsite. The route
|
||||
* group `(legal)` is invisible in the URL — pages are reachable at their
|
||||
* top-level paths.
|
||||
*/
|
||||
export default function LegalLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main style={mainStyle}>{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const mainStyle: CSSProperties = {
|
||||
maxWidth: 760,
|
||||
margin: '120px auto 96px',
|
||||
padding: '0 24px',
|
||||
fontFamily: "var(--sans)",
|
||||
color: 'var(--hive-100, #ece3d0)',
|
||||
};
|
||||
208
apps/www/app/(legal)/privacy/page.tsx
Normal file
208
apps/www/app/(legal)/privacy/page.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Privacy Policy — Waggle',
|
||||
description:
|
||||
'Privacy policy for Waggle OS by Egzakta Group d.o.o.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 style={h1Style}>Privacy Policy</h1>
|
||||
<p style={metaLineStyle}>
|
||||
<strong>Effective date:</strong> July 8, 2026 · <strong>Last updated:</strong> July 8, 2026
|
||||
</p>
|
||||
<p style={paragraphStyle}>
|
||||
Waggle OS is provided by Egzakta Group d.o.o. (“Egzakta”,
|
||||
“we”, “us”), a company registered in the
|
||||
Republic of Serbia. This Privacy Policy explains what personal data
|
||||
we collect, how we use it, and your rights.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>1. Data we collect</h2>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
<strong>Account data</strong>: when you sign up via Clerk, we collect
|
||||
your email address, display name, and optional profile information.
|
||||
Clerk Inc. processes this data as an authentication sub-processor
|
||||
under their own privacy policy (
|
||||
<a href="https://clerk.com/privacy" style={linkStyle}>
|
||||
clerk.com/privacy
|
||||
</a>
|
||||
).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
<strong>Subscription data</strong>: when you upgrade to Team,
|
||||
Stripe Inc. processes your payment information. We never see or
|
||||
store your card details — Stripe returns only a customer ID and
|
||||
subscription status to us (
|
||||
<a href="https://stripe.com/privacy" style={linkStyle}>
|
||||
stripe.com/privacy
|
||||
</a>
|
||||
).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
<strong>Product analytics</strong>: with your opt-in (default off),
|
||||
we collect anonymous usage events (onboarding completion, feature
|
||||
interactions) via PostHog Inc. to improve the product. You can opt
|
||||
out at any time in Settings → Privacy (
|
||||
<a href="https://posthog.com/privacy" style={linkStyle}>
|
||||
posthog.com/privacy
|
||||
</a>
|
||||
).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
<strong>Local memory</strong>: Waggle OS stores your conversations,
|
||||
memories, and harvested content <strong>locally on your device</strong>{' '}
|
||||
in a SQLite database (the <code>.waggle/</code> directory). We do
|
||||
not transmit this data to our servers. If you choose to enable
|
||||
shared team workspaces (Teams tier), encrypted memory bundles are
|
||||
stored on our infrastructure for synchronization; you control which
|
||||
workspaces sync.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 style={h2Style}>2. How we use your data</h2>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
Provide the Service (authentication, subscription management, support).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Improve the Service (analytics where you’ve opted in).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Comply with legal obligations (tax records, fraud prevention).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
We do not sell your personal data and do not use it for advertising.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 style={h2Style}>3. Where your data lives</h2>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
Account and subscription data: Clerk and Stripe (US- and EU-region
|
||||
processors).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Analytics (opt-in only): PostHog (US-region).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Memory and conversations: your device. Optionally your Teams
|
||||
workspace bundle.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Egzakta’s own systems: minimal account and billing metadata in
|
||||
encrypted storage.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 style={h2Style}>4. Your rights (GDPR and similar regimes)</h2>
|
||||
<p style={paragraphStyle}>
|
||||
You have the right to access, rectify, port, restrict, or delete your
|
||||
personal data, and to object to processing. To exercise these rights,
|
||||
email <strong>privacy@egzakta.com</strong>. We aim to respond within
|
||||
30 days.
|
||||
</p>
|
||||
<p style={paragraphStyle}>
|
||||
Local memory data: you can erase all locally-stored data at any time
|
||||
via Settings → Privacy → Erase All Data. This is irreversible and
|
||||
does not require contacting us.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>5. Retention</h2>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
Account data: retained while your account is active plus 90 days
|
||||
after deletion for billing reconciliation.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Subscription data: retained per Stripe’s record-keeping
|
||||
requirements (typically 7 years for accounting).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Analytics (opt-in only): retained 12 months.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Local memory: retained until you delete it. Egzakta has no access.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 style={h2Style}>6. Cookies and tracking</h2>
|
||||
<p style={paragraphStyle}>
|
||||
We use strictly-necessary cookies for authentication (Clerk session)
|
||||
and, with your opt-in, analytics cookies (PostHog). See our{' '}
|
||||
<a href="/cookies" style={linkStyle}>
|
||||
Cookie Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>7. Changes to this policy</h2>
|
||||
<p style={paragraphStyle}>
|
||||
We may update this policy. Material changes will be announced via the
|
||||
product and email. Continued use after a change means you accept the
|
||||
updated terms.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>8. Contact</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Egzakta Group d.o.o.
|
||||
<br />
|
||||
Registered office: Belgrade, Republic of Serbia
|
||||
<br />
|
||||
Email: <strong>privacy@egzakta.com</strong>
|
||||
<br />
|
||||
Data Protection Officer: <strong>dpo@egzakta.com</strong>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const h1Style: CSSProperties = {
|
||||
fontSize: 'clamp(28px, 4vw, 36px)',
|
||||
fontWeight: 700,
|
||||
marginBottom: 24,
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
};
|
||||
|
||||
const h2Style: CSSProperties = {
|
||||
fontSize: 'clamp(18px, 2.4vw, 22px)',
|
||||
fontWeight: 600,
|
||||
marginTop: 32,
|
||||
marginBottom: 12,
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
};
|
||||
|
||||
const metaLineStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: 'var(--hive-300, #948a73)',
|
||||
marginBottom: 24,
|
||||
};
|
||||
|
||||
const paragraphStyle: CSSProperties = {
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
color: 'var(--hive-200, #d8cfba)',
|
||||
marginBottom: 16,
|
||||
};
|
||||
|
||||
const listStyle: CSSProperties = {
|
||||
paddingLeft: 24,
|
||||
marginBottom: 16,
|
||||
color: 'var(--hive-200, #d8cfba)',
|
||||
};
|
||||
|
||||
const listItemStyle: CSSProperties = {
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
const linkStyle: CSSProperties = {
|
||||
color: 'var(--honey-400, #f6c45a)',
|
||||
textDecoration: 'underline',
|
||||
};
|
||||
198
apps/www/app/(legal)/terms/page.tsx
Normal file
198
apps/www/app/(legal)/terms/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Terms of Service — Waggle',
|
||||
description:
|
||||
'Terms of Service for Waggle OS by Egzakta Group d.o.o.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 style={h1Style}>Terms of Service</h1>
|
||||
<p style={metaLineStyle}>
|
||||
<strong>Effective date:</strong> July 8, 2026 · <strong>Last updated:</strong> July 8, 2026
|
||||
</p>
|
||||
<p style={paragraphStyle}>
|
||||
These Terms of Service (“Terms”) govern your use of Waggle
|
||||
OS (the “Service”), provided by Egzakta Group d.o.o.
|
||||
(“Egzakta”, “we”, “us”). By
|
||||
creating an account or installing the Waggle desktop application, you
|
||||
agree to these Terms.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>1. The Service</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Waggle OS is a desktop AI agent platform with persistent local memory.
|
||||
The Service includes the Waggle desktop application, the cloud-hosted
|
||||
account infrastructure, and optional team collaboration features.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>2. Account</h2>
|
||||
<p style={paragraphStyle}>
|
||||
You must be at least 16 years old to use the Service. You are
|
||||
responsible for maintaining the confidentiality of your account
|
||||
credentials. You agree to provide accurate and current information
|
||||
when creating an account.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>3. Acceptable use</h2>
|
||||
<p style={paragraphStyle}>You agree not to:</p>
|
||||
<ul style={listStyle}>
|
||||
<li style={listItemStyle}>
|
||||
Use the Service to violate any law or third-party rights.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Attempt to reverse-engineer, modify, or interfere with the
|
||||
Service’s protections.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Use the Service to generate content that infringes intellectual
|
||||
property, defames individuals, or violates privacy.
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Submit data you do not have the right to submit (including
|
||||
confidential or copyrighted third-party content).
|
||||
</li>
|
||||
<li style={listItemStyle}>
|
||||
Use the Service to develop or train competing AI products.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 style={h2Style}>4. Subscription and billing</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Solo tier: free, forever, for all personal use — full memory,
|
||||
Harvest import, unlimited workspaces, and any model with your own
|
||||
keys. Team tier (USD 49/seat/month): adds shared workspaces and team
|
||||
collaboration.
|
||||
</p>
|
||||
<p style={paragraphStyle}>
|
||||
Subscriptions are billed in advance through Stripe. Auto-renewal is
|
||||
on by default; you can cancel at any time in account settings.
|
||||
Cancellations take effect at the end of the current billing period.
|
||||
We do not refund partial periods except where required by law.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>5. Trial</h2>
|
||||
<p style={paragraphStyle}>
|
||||
A free 15-day trial of all features is available to new accounts. If
|
||||
you do not subscribe at trial end, your account converts to the Solo
|
||||
tier and trial features become inaccessible. Trial data is retained
|
||||
per the{' '}
|
||||
<a href="/privacy" style={linkStyle}>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>6. Intellectual property</h2>
|
||||
<p style={paragraphStyle}>
|
||||
You retain ownership of content you create, store, or process via the
|
||||
Service. You grant Egzakta a limited license to host and process your
|
||||
content solely to provide the Service.
|
||||
</p>
|
||||
<p style={paragraphStyle}>
|
||||
Egzakta retains all rights in the Service, including source code,
|
||||
documentation, and marketing materials. Open-source components are
|
||||
governed by their respective licenses (notably Apache 2.0 for the
|
||||
hive-mind substrate).
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>7. Disclaimer of warranties</h2>
|
||||
<p style={paragraphStyle}>
|
||||
The Service is provided “as is” without warranties of any
|
||||
kind. We do not warrant that the Service will be uninterrupted,
|
||||
error-free, or that AI-generated outputs will be accurate, complete,
|
||||
or fit for any particular purpose. You are responsible for verifying
|
||||
AI-generated content before acting on it.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>8. Limitation of liability</h2>
|
||||
<p style={paragraphStyle}>
|
||||
To the maximum extent permitted by law, Egzakta’s total
|
||||
liability for any claim arising from the Service is limited to the
|
||||
fees you paid Egzakta in the 12 months preceding the claim. We are
|
||||
not liable for indirect, incidental, or consequential damages.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>9. Termination</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Either party may terminate at any time. We may suspend or terminate
|
||||
your account for material breach of these Terms. Upon termination,
|
||||
you may export your data for 30 days; after that, we may delete your
|
||||
account data in accordance with the Privacy Policy.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>10. Governing law</h2>
|
||||
<p style={paragraphStyle}>
|
||||
These Terms are governed by the laws of the Republic of Serbia.
|
||||
Disputes shall be resolved in the competent courts of Belgrade,
|
||||
Serbia, except where mandatory consumer protection laws in your
|
||||
jurisdiction provide otherwise.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>11. Changes to the Terms</h2>
|
||||
<p style={paragraphStyle}>
|
||||
We may update these Terms. Material changes will be announced at
|
||||
least 30 days in advance via the product and email. Continued use
|
||||
after the change means you accept the updated Terms.
|
||||
</p>
|
||||
|
||||
<h2 style={h2Style}>12. Contact</h2>
|
||||
<p style={paragraphStyle}>
|
||||
Egzakta Group d.o.o.
|
||||
<br />
|
||||
Registered office: Belgrade, Republic of Serbia
|
||||
<br />
|
||||
Email: <strong>legal@egzakta.com</strong>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const h1Style: CSSProperties = {
|
||||
fontSize: 'clamp(28px, 4vw, 36px)',
|
||||
fontWeight: 700,
|
||||
marginBottom: 24,
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
};
|
||||
|
||||
const h2Style: CSSProperties = {
|
||||
fontSize: 'clamp(18px, 2.4vw, 22px)',
|
||||
fontWeight: 600,
|
||||
marginTop: 32,
|
||||
marginBottom: 12,
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
};
|
||||
|
||||
const metaLineStyle: CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: 'var(--hive-300, #948a73)',
|
||||
marginBottom: 24,
|
||||
};
|
||||
|
||||
const paragraphStyle: CSSProperties = {
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
color: 'var(--hive-200, #d8cfba)',
|
||||
marginBottom: 16,
|
||||
};
|
||||
|
||||
const listStyle: CSSProperties = {
|
||||
paddingLeft: 24,
|
||||
marginBottom: 16,
|
||||
color: 'var(--hive-200, #d8cfba)',
|
||||
};
|
||||
|
||||
const listItemStyle: CSSProperties = {
|
||||
fontSize: 15,
|
||||
lineHeight: 1.7,
|
||||
marginBottom: 8,
|
||||
};
|
||||
|
||||
const linkStyle: CSSProperties = {
|
||||
color: 'var(--honey-400, #f6c45a)',
|
||||
textDecoration: 'underline',
|
||||
};
|
||||
58
apps/www/app/_components/BrandMark.tsx
Normal file
58
apps/www/app/_components/BrandMark.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
interface BrandMarkProps {
|
||||
readonly size?: number;
|
||||
readonly withWordmark?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waggle brand mark: a hive cell (pointy-top hexagon) holding a honey core —
|
||||
* one memory node in the graph. Pure SVG so it stays crisp at every density
|
||||
* and inherits no JPEG artifacts (replaces the legacy logo.jpeg raster).
|
||||
*/
|
||||
export default function BrandMark({ size = 28, withWordmark = false }: BrandMarkProps) {
|
||||
const mark = (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Waggle"
|
||||
>
|
||||
<path
|
||||
d="M16 3 L27.26 9.5 L27.26 22.5 L16 29 L4.74 22.5 L4.74 9.5 Z"
|
||||
stroke="var(--honey-500, #e9a52c)"
|
||||
strokeWidth="2.4"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M16 10.8 L20.5 13.4 L20.5 18.6 L16 21.2 L11.5 18.6 L11.5 13.4 Z"
|
||||
fill="var(--honey-400, #f6c45a)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
if (!withWordmark) return mark;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{mark}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
}}
|
||||
>
|
||||
Waggle
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
537
apps/www/app/_components/BrandPersonasCard.tsx
Normal file
537
apps/www/app/_components/BrandPersonasCard.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
HEX_TEXTURE_PATH,
|
||||
personas,
|
||||
type Persona,
|
||||
type PersonaSlug,
|
||||
} from '../_data/personas';
|
||||
|
||||
/**
|
||||
* 4x4 grid sequence in row-major order. `'filler'` slots occupy top-left,
|
||||
* top-right, and bottom-right corners. Numbers reference `Persona.order`.
|
||||
*/
|
||||
const LANDING_GRID_SEQUENCE: ReadonlyArray<'filler' | number> = [
|
||||
'filler', 1, 2, 'filler',
|
||||
3, 4, 5, 6,
|
||||
7, 8, 9, 10,
|
||||
11, 12, 13, 'filler',
|
||||
];
|
||||
|
||||
const personaByOrder = new Map<number, Persona>(
|
||||
personas.map((p) => [p.order, p]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-persona accent — a curated warm ramp (honey / amber / copper / bronze /
|
||||
* terracotta family; no purple, no gradients). Round-6 palette discipline:
|
||||
* exactly ONE muted cool note survives (sleeping → night blue, where the hue
|
||||
* IS the meaning); everything else stays in the warm-Hive family. Each tile
|
||||
* gets its hue on the title, top hairline, and hover border/glow so the grid
|
||||
* reads as a cast of characters, not a spreadsheet. Hues are chosen so
|
||||
* horizontally/vertically adjacent tiles (4-col landing grid) never repeat,
|
||||
* and a few map to meaning (confused → terracotta flag, researcher/team →
|
||||
* deep bronze, analyst/architect → copper).
|
||||
*/
|
||||
const PERSONA_ACCENTS: Readonly<Record<PersonaSlug, string>> = {
|
||||
hunter: '#f6c45a',
|
||||
researcher: '#c07e16',
|
||||
analyst: '#d98a3d',
|
||||
connector: '#f2b950',
|
||||
architect: '#d98a3d',
|
||||
builder: '#e9a52c',
|
||||
writer: '#e0916f',
|
||||
orchestrator: '#e9a52c',
|
||||
marketer: '#f6c45a',
|
||||
team: '#c07e16',
|
||||
celebrating: '#f9d27e',
|
||||
confused: '#db8068',
|
||||
sleeping: '#86a9d1',
|
||||
};
|
||||
|
||||
export interface BrandPersonasCardProps {
|
||||
/** Optional uppercase kicker rendered above the heading (e.g. "Built for"). */
|
||||
eyebrow?: string;
|
||||
heading?: string;
|
||||
subtitle?: string;
|
||||
showFillerTiles?: boolean;
|
||||
variant?: 'landing' | 'compact';
|
||||
onTileHover?: (slug: PersonaSlug) => void;
|
||||
onPersonaClick?: (slug: PersonaSlug) => void;
|
||||
cta?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-surface canon of the 13 Waggle bee personas.
|
||||
*
|
||||
* @remarks
|
||||
* Copy is imported verbatim from `_data/personas.ts` — do not override in-place.
|
||||
* Assets are loaded eagerly via `next/image` (optimizer serves ~256px
|
||||
* AVIF/WebP of the 2048px source PNGs) with an `onError` fallback that flips
|
||||
* the tile to a hex-texture placeholder. The placeholder auto-disables when an
|
||||
* asset loads successfully, so shipping new PNGs requires no code change.
|
||||
*
|
||||
* @todo compact variant scaffolding — implement in future sprint
|
||||
*/
|
||||
export default function BrandPersonasCard({
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle,
|
||||
showFillerTiles = true,
|
||||
variant = 'landing',
|
||||
onTileHover,
|
||||
onPersonaClick,
|
||||
cta,
|
||||
}: BrandPersonasCardProps) {
|
||||
const t = useTranslations('landing.brand_personas');
|
||||
const resolvedHeading = heading ?? t('default_heading');
|
||||
const resolvedSubtitle = subtitle ?? t('default_subtitle');
|
||||
const [erroredSlugs, setErroredSlugs] = useState<ReadonlySet<PersonaSlug>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const handleAssetError = useCallback((slug: PersonaSlug) => {
|
||||
setErroredSlugs((prev) => {
|
||||
if (prev.has(slug)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(slug);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div
|
||||
data-testid="brand-personas-card-compact"
|
||||
data-variant="compact"
|
||||
aria-label={t('compact_aria')}
|
||||
>
|
||||
{/* Compact variant scaffolding — intentional stub.
|
||||
TypeScript interface is stable; parent pages may wire props today
|
||||
and receive a fuller layout in a future sprint without refactor. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="brand-personas-card"
|
||||
data-variant="landing"
|
||||
aria-labelledby="waggle-hive-heading"
|
||||
style={sectionStyle}
|
||||
>
|
||||
<header style={headerStyle}>
|
||||
{eyebrow ? <p style={eyebrowStyle}>{eyebrow}</p> : null}
|
||||
<h2 id="waggle-hive-heading" style={headingStyle}>
|
||||
{resolvedHeading}
|
||||
</h2>
|
||||
<p style={subtitleStyle}>{resolvedSubtitle}</p>
|
||||
</header>
|
||||
|
||||
<ul
|
||||
role="list"
|
||||
data-testid="brand-personas-grid"
|
||||
className="waggle-persona-grid"
|
||||
>
|
||||
{LANDING_GRID_SEQUENCE.map((entry, index) => {
|
||||
if (entry === 'filler') {
|
||||
if (!showFillerTiles) return null;
|
||||
return (
|
||||
<li
|
||||
key={`filler-${index}`}
|
||||
aria-hidden="true"
|
||||
data-testid="brand-personas-filler"
|
||||
className="waggle-persona-filler"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const persona = personaByOrder.get(entry);
|
||||
if (!persona) {
|
||||
// Guard: should never happen — sequence mirrors canonical order.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PersonaTile
|
||||
key={persona.slug}
|
||||
persona={persona}
|
||||
hasError={erroredSlugs.has(persona.slug)}
|
||||
onAssetError={handleAssetError}
|
||||
onPersonaClick={onPersonaClick}
|
||||
onTileHover={onTileHover}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{cta ? (
|
||||
<div data-testid="brand-personas-cta" style={ctaWrapperStyle}>
|
||||
{cta}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<style>{scopedCss}</style>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface PersonaTileProps {
|
||||
persona: Persona;
|
||||
hasError: boolean;
|
||||
onAssetError: (slug: PersonaSlug) => void;
|
||||
onPersonaClick?: (slug: PersonaSlug) => void;
|
||||
onTileHover?: (slug: PersonaSlug) => void;
|
||||
}
|
||||
|
||||
function PersonaTile({
|
||||
persona,
|
||||
hasError,
|
||||
onAssetError,
|
||||
onPersonaClick,
|
||||
onTileHover,
|
||||
}: PersonaTileProps) {
|
||||
const handleClick = useCallback(() => {
|
||||
onPersonaClick?.(persona.slug);
|
||||
}, [onPersonaClick, persona.slug]);
|
||||
|
||||
const handleKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLElement>) => {
|
||||
if (!onPersonaClick) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onPersonaClick(persona.slug);
|
||||
}
|
||||
},
|
||||
[onPersonaClick, persona.slug],
|
||||
);
|
||||
|
||||
const handleHover = useCallback(() => {
|
||||
onTileHover?.(persona.slug);
|
||||
}, [onTileHover, persona.slug]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
// Keyboard-only "hover" equivalent so a11y consumers get the same signal.
|
||||
onTileHover?.(persona.slug);
|
||||
}, [onTileHover, persona.slug]);
|
||||
|
||||
const isInteractive = Boolean(onPersonaClick);
|
||||
|
||||
const figure = (
|
||||
<figure className="waggle-persona-figure">
|
||||
<div className="waggle-persona-asset-frame">
|
||||
{hasError ? (
|
||||
<div
|
||||
data-testid={`persona-placeholder-${persona.slug}`}
|
||||
data-placeholder="true"
|
||||
className="waggle-persona-placeholder"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="waggle-persona-placeholder-dot" />
|
||||
</div>
|
||||
) : (
|
||||
/* next/image (optimizer → ~256px AVIF/WebP) makes eager loading
|
||||
affordable; the raw 2048px PNGs are ~2.5 MB each. Eager so a
|
||||
tile never paints as an empty frame while lazy IO waits. */
|
||||
<Image
|
||||
src={persona.imagePath}
|
||||
alt={persona.alt}
|
||||
loading="eager"
|
||||
width={256}
|
||||
height={256}
|
||||
className="waggle-persona-asset"
|
||||
onError={() => onAssetError(persona.slug)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<figcaption className="waggle-persona-caption">
|
||||
<strong className="waggle-persona-title">{persona.title}</strong>
|
||||
<span className="waggle-persona-role">{persona.role}</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
|
||||
const tileStyle = {
|
||||
'--accent': PERSONA_ACCENTS[persona.slug],
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<li
|
||||
data-testid={`persona-tile-${persona.slug}`}
|
||||
data-slug={persona.slug}
|
||||
data-placeholder={hasError ? 'true' : undefined}
|
||||
className="waggle-persona-tile"
|
||||
style={tileStyle}
|
||||
onMouseEnter={handleHover}
|
||||
onFocus={handleFocus}
|
||||
>
|
||||
{isInteractive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="waggle-persona-button"
|
||||
aria-label={persona.alt}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKey}
|
||||
>
|
||||
{figure}
|
||||
</button>
|
||||
) : (
|
||||
figure
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Inline styles (match existing apps/www convention — no Tailwind) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
background: 'var(--hive-950, #0e0c07)',
|
||||
padding: '96px 24px',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const headerStyle: CSSProperties = {
|
||||
maxWidth: 1200,
|
||||
margin: '0 auto 48px',
|
||||
textAlign: 'center',
|
||||
};
|
||||
|
||||
const eyebrowStyle: CSSProperties = {
|
||||
fontFamily: "var(--sans)",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em',
|
||||
color: 'var(--honey-500, #e9a52c)',
|
||||
margin: 0,
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const headingStyle: CSSProperties = {
|
||||
fontFamily: "var(--sans)",
|
||||
fontSize: 'clamp(28px, 4vw, 32px)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
margin: 0,
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const subtitleStyle: CSSProperties = {
|
||||
fontFamily: "var(--sans)",
|
||||
fontSize: 'clamp(16px, 2vw, 18px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--hive-300, #c8bfa9)',
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const ctaWrapperStyle: CSSProperties = {
|
||||
maxWidth: 1200,
|
||||
margin: '48px auto 0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Scoped CSS — component-local selectors to avoid global collisions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const scopedCss = `
|
||||
.waggle-persona-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.waggle-persona-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.waggle-persona-filler { display: none; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.waggle-persona-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.waggle-persona-tile {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #14110b 0%, #0e0c07 100%);
|
||||
border: 1px solid #1f1a12;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
min-height: 260px;
|
||||
transition: border-color 200ms ease-out, transform 200ms ease-out,
|
||||
box-shadow 200ms ease-out;
|
||||
}
|
||||
/* Per-role accent hairline across the top edge of each tile. */
|
||||
.waggle-persona-tile::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent, #e9a52c);
|
||||
opacity: 0.5;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
.waggle-persona-tile:hover,
|
||||
.waggle-persona-tile:focus-within {
|
||||
border-color: var(--accent, #e9a52c);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 34px -16px color-mix(in srgb, var(--accent, #e9a52c) 55%, transparent);
|
||||
}
|
||||
.waggle-persona-tile:hover::before,
|
||||
.waggle-persona-tile:focus-within::before {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.waggle-persona-tile,
|
||||
.waggle-persona-tile:hover,
|
||||
.waggle-persona-tile:focus-within {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.waggle-persona-button {
|
||||
all: unset;
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.waggle-persona-button:focus-visible {
|
||||
outline: 2px solid var(--accent, #e9a52c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.waggle-persona-figure {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.waggle-persona-asset-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-width: 256px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.waggle-persona-asset {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
/* The mascot PNGs are background-transparent (2026-07-06 flood-fill) —
|
||||
they sit directly on the card gradient. The old dark vignette +
|
||||
edge-fade masks compensated for baked-black squares and are gone:
|
||||
with real alpha they READ as a dark box behind the art. */
|
||||
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.waggle-persona-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url("${HEX_TEXTURE_PATH}");
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #14110b;
|
||||
opacity: 0.6;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.waggle-persona-placeholder-dot {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #f6c45a;
|
||||
box-shadow: 0 0 24px rgba(246, 196, 90, 0.4);
|
||||
}
|
||||
.waggle-persona-caption {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.waggle-persona-title {
|
||||
font-family: var(--sans);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #f6c45a);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.waggle-persona-role {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #c8bfa9;
|
||||
line-height: 1.45;
|
||||
}
|
||||
/* Filler slots keep the staggered grid rhythm without reading as broken
|
||||
cards: no border, no card surface — an outlined-but-hollow frame looks
|
||||
like missing content. Instead: ambient comb texture that fades out
|
||||
radially, so the corners read as intentional negative space.
|
||||
Decorative only (aria-hidden on the element). */
|
||||
.waggle-persona-filler {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
min-height: 260px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
/* Round-7: complete the falloff — the texture dissolves toward the grid's
|
||||
outer edge so the ghost reads as intentional ambience, never as an
|
||||
unloaded card. */
|
||||
-webkit-mask-image: linear-gradient(to right, rgba(0,0,0,0.8), rgba(0,0,0,0.15));
|
||||
mask-image: linear-gradient(to right, rgba(0,0,0,0.8), rgba(0,0,0,0.15));
|
||||
}
|
||||
.waggle-persona-filler:nth-of-type(1),
|
||||
li.waggle-persona-filler:first-child {
|
||||
-webkit-mask-image: linear-gradient(to left, rgba(0,0,0,0.8), rgba(0,0,0,0.15));
|
||||
mask-image: linear-gradient(to left, rgba(0,0,0,0.8), rgba(0,0,0,0.15));
|
||||
}
|
||||
/* Round-6: fainter + slightly shrunken so the ghosts can't be mistaken for
|
||||
unloaded cards — clearly ambient texture, not content-in-waiting. */
|
||||
.waggle-persona-filler::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 46%, rgba(233, 165, 44, 0.10), rgba(233, 165, 44, 0) 62%),
|
||||
url("${HEX_TEXTURE_PATH}") center / cover no-repeat;
|
||||
opacity: 0.2;
|
||||
transform: scale(0.88);
|
||||
-webkit-mask-image: radial-gradient(circle at 50% 50%, #000 22%, transparent 74%);
|
||||
mask-image: radial-gradient(circle at 50% 50%, #000 22%, transparent 74%);
|
||||
}
|
||||
.waggle-persona-filler::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 34px;
|
||||
height: 38px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: no-repeat center / contain
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='42' height='46' viewBox='0 0 42 46' fill='none'%3E%3Cpath d='M21 2 L39 12.5 V33.5 L21 44 L3 33.5 V12.5 Z' stroke='%23e9a52c' stroke-width='1.4' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
opacity: 0.18;
|
||||
}
|
||||
`;
|
||||
71
apps/www/app/_components/DownloadCTA.tsx
Normal file
71
apps/www/app/_components/DownloadCTA.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { detectOSFromUserAgent, type OSId } from '../_lib/os-detection';
|
||||
import { emit, events } from '../_lib/event-taxonomy';
|
||||
|
||||
interface DownloadCTAProps {
|
||||
readonly variant?: 'primary' | 'ghost';
|
||||
readonly size?: 'default' | 'small';
|
||||
readonly section: 'hero' | 'navbar' | 'solo-tier' | 'final-cta';
|
||||
readonly children?: ReactNode;
|
||||
readonly style?: CSSProperties;
|
||||
}
|
||||
|
||||
const DOWNLOAD_URL = '/download';
|
||||
|
||||
/**
|
||||
* OS-aware download CTA. Renders a generic "Download" label at SSR + first
|
||||
* paint, then swaps to "Download for {os}" after hydration via
|
||||
* `navigator.userAgent` detection.
|
||||
*
|
||||
* Styling comes from the shared `.btn` primitives in globals.css so every
|
||||
* download button on the page is pixel-identical. Strings live in
|
||||
* `messages/en.json` under `landing.download_cta.*` with an ICU placeholder
|
||||
* for the OS name.
|
||||
*/
|
||||
export default function DownloadCTA({
|
||||
variant = 'primary',
|
||||
size = 'default',
|
||||
section,
|
||||
children,
|
||||
style,
|
||||
}: DownloadCTAProps) {
|
||||
const t = useTranslations('landing.download_cta');
|
||||
const [os, setOS] = useState<OSId | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
setOS(detectOSFromUserAgent(navigator.userAgent));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const label = children ?? (os ? t('with_os', { os }) : t('default'));
|
||||
|
||||
const className = [
|
||||
'btn',
|
||||
variant === 'primary' ? 'btn-primary' : 'btn-ghost',
|
||||
size === 'small' ? 'btn-small' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const handleClick = () => {
|
||||
emit({
|
||||
name: events.ctaClick,
|
||||
properties: { section, os: os ?? 'unknown' },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={DOWNLOAD_URL}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
style={style}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
67
apps/www/app/_components/FeatureGrid.module.css
Normal file
67
apps/www/app/_components/FeatureGrid.module.css
Normal file
@@ -0,0 +1,67 @@
|
||||
.header {
|
||||
max-width: 720px;
|
||||
margin: 0 auto 64px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 28px 26px 30px;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--hive-900);
|
||||
border: 1px solid var(--line-soft);
|
||||
transition: border-color 0.2s ease, transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
border-color: var(--honey-line);
|
||||
box-shadow: var(--shadow-honey);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid var(--honey-line);
|
||||
background: var(--honey-wash);
|
||||
color: var(--honey-400);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
127
apps/www/app/_components/FeatureGrid.tsx
Normal file
127
apps/www/app/_components/FeatureGrid.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './FeatureGrid.module.css';
|
||||
|
||||
type FeatureKey =
|
||||
| 'personas'
|
||||
| 'models'
|
||||
| 'harvest'
|
||||
| 'loops'
|
||||
| 'skills'
|
||||
| 'memory_center';
|
||||
|
||||
const FEATURES: readonly FeatureKey[] = [
|
||||
'personas',
|
||||
'models',
|
||||
'harvest',
|
||||
'loops',
|
||||
'skills',
|
||||
'memory_center',
|
||||
];
|
||||
|
||||
/**
|
||||
* Six feature cards, each naming a real subsystem (persona roster, LiteLLM
|
||||
* routing, Harvest, Loops + approval queue, skills/connectors/MCP catalog,
|
||||
* Memory Center). Strings under `landing.features.*`.
|
||||
*/
|
||||
export default async function FeatureGrid() {
|
||||
const t = await getTranslations('landing.features');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="features"
|
||||
className="section"
|
||||
aria-labelledby="features-heading"
|
||||
>
|
||||
<div className="container-wide">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="features-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{FEATURES.map((key, i) => (
|
||||
<Reveal key={key} delay={((i % 3) + 1) as 1 | 2 | 3}>
|
||||
<div className={styles.item}>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
<FeatureIcon feature={key} />
|
||||
</span>
|
||||
<h3 className={styles.title}>{t(`items.${key}.title`)}</h3>
|
||||
<p className={styles.body}>{t(`items.${key}.body`)}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal 18px line icons — one per subsystem, stroke inherits currentColor. */
|
||||
function FeatureIcon({ feature }: { readonly feature: FeatureKey }) {
|
||||
const common = {
|
||||
width: 18,
|
||||
height: 18,
|
||||
viewBox: '0 0 18 18',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.5,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
} as const;
|
||||
|
||||
switch (feature) {
|
||||
case 'personas':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<circle cx="6" cy="6.5" r="2.6" />
|
||||
<path d="M1.8 14.8 C2.4 11.8 4.4 10.6 6 10.6 C7.6 10.6 9.6 11.8 10.2 14.8" />
|
||||
<circle cx="12.8" cy="5.4" r="2" />
|
||||
<path d="M10.9 9.4 C12 8.8 14.6 9 16.2 12.4" />
|
||||
</svg>
|
||||
);
|
||||
case 'models':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<circle cx="9" cy="9" r="2.2" />
|
||||
<path d="M9 6.8 V2.2 M9 11.2 V15.8 M6.8 9 H2.2 M11.2 9 H15.8" />
|
||||
<circle cx="9" cy="2.2" r="1.2" />
|
||||
<circle cx="9" cy="15.8" r="1.2" />
|
||||
<circle cx="2.2" cy="9" r="1.2" />
|
||||
<circle cx="15.8" cy="9" r="1.2" />
|
||||
</svg>
|
||||
);
|
||||
case 'harvest':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<path d="M9 2 V11 M5.4 7.6 L9 11.2 L12.6 7.6" />
|
||||
<path d="M2.5 12.5 V14.5 C2.5 15.3 3.2 16 4 16 H14 C14.8 16 15.5 15.3 15.5 14.5 V12.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'loops':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<path d="M14.5 7 A6 6 0 1 0 15.5 10.5" />
|
||||
<path d="M15.8 3.4 L15.8 7.2 L12 7.2" />
|
||||
</svg>
|
||||
);
|
||||
case 'skills':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<rect x="2.4" y="2.4" width="5.6" height="5.6" rx="1.4" />
|
||||
<rect x="10" y="2.4" width="5.6" height="5.6" rx="1.4" />
|
||||
<rect x="2.4" y="10" width="5.6" height="5.6" rx="1.4" />
|
||||
<path d="M12.8 10.4 V15.2 M10.4 12.8 H15.2" />
|
||||
</svg>
|
||||
);
|
||||
case 'memory_center':
|
||||
return (
|
||||
<svg {...common} aria-hidden="true">
|
||||
<path d="M9 1.8 L15.2 5.4 L15.2 12.6 L9 16.2 L2.8 12.6 L2.8 5.4 Z" />
|
||||
<circle cx="9" cy="9" r="2.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
71
apps/www/app/_components/FinalCTA.module.css
Normal file
71
apps/www/app/_components/FinalCTA.module.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.section {
|
||||
position: relative;
|
||||
padding: var(--section-pad) var(--gutter);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
bottom: -300px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 900px;
|
||||
height: 520px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(233, 165, 44, 0.1) 0%,
|
||||
rgba(233, 165, 44, 0.03) 45%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: relative;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: clamp(2.125rem, 4.5vw, 3.25rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
font-size: var(--text-lead);
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.ctaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.kvark {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kvarkLink {
|
||||
color: var(--honey-400);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.kvarkLink:hover {
|
||||
color: var(--honey-300);
|
||||
}
|
||||
51
apps/www/app/_components/FinalCTA.tsx
Normal file
51
apps/www/app/_components/FinalCTA.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import DownloadCTA from './DownloadCTA';
|
||||
import styles from './FinalCTA.module.css';
|
||||
|
||||
const WAGGLE_REPO_URL = 'https://github.com/marolinik/waggle-os';
|
||||
const KVARK_CONTACT =
|
||||
'mailto:kvark@egzakta.com?subject=Waggle%20%E2%86%92%20KVARK%20sovereign%20deployment';
|
||||
|
||||
/**
|
||||
* Closing CTA — echoes the hero promise, then routes to Download or GitHub,
|
||||
* with the KVARK sovereign-deployment escape hatch underneath. Strings
|
||||
* under `landing.final_cta.*`.
|
||||
*/
|
||||
export default async function FinalCTA() {
|
||||
const t = await getTranslations('landing.final_cta');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="final-cta"
|
||||
className={`${styles.section} honeycomb-bg`}
|
||||
aria-labelledby="final-heading"
|
||||
>
|
||||
<div className={styles.glow} aria-hidden="true" />
|
||||
<div className={styles.inner}>
|
||||
<h2 id="final-heading" className={styles.headline}>
|
||||
{t('headline')}
|
||||
</h2>
|
||||
<p className={styles.subhead}>{t('subhead')}</p>
|
||||
|
||||
<div className={styles.ctaRow}>
|
||||
<DownloadCTA section="final-cta" variant="primary" />
|
||||
<a
|
||||
href={WAGGLE_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
{t('cta_github')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className={styles.kvark}>
|
||||
{t('kvark_text')}
|
||||
<a href={KVARK_CONTACT} className={styles.kvarkLink}>
|
||||
{t('kvark_cta')} →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
95
apps/www/app/_components/Footer.module.css
Normal file
95
apps/www/app/_components/Footer.module.css
Normal file
@@ -0,0 +1,95 @@
|
||||
.footer {
|
||||
padding: 72px var(--gutter) 32px;
|
||||
background: var(--hive-950);
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.grid {
|
||||
max-width: var(--container-wide);
|
||||
margin: 0 auto 56px;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.brandBlock {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.brandDescription {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
margin-top: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.attribution {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.columnTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--hive-200);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.columnList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.columnList li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.columnLink {
|
||||
font-size: 13px;
|
||||
color: var(--hive-300);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.columnLink:hover {
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
.baseline {
|
||||
max-width: var(--container-wide);
|
||||
margin: 0 auto;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--hive-800);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.baselineRight {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.baseline {
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
105
apps/www/app/_components/Footer.tsx
Normal file
105
apps/www/app/_components/Footer.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import BrandMark from './BrandMark';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
interface FooterLink {
|
||||
readonly key: string;
|
||||
readonly href: string;
|
||||
readonly external?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer link map. Rule: every link must resolve to a real destination —
|
||||
* no `#` placeholders. Columns whose content does not exist yet (blog,
|
||||
* press, changelog) are omitted until they do.
|
||||
*/
|
||||
const PRODUCT_LINKS: readonly FooterLink[] = [
|
||||
{
|
||||
key: 'download',
|
||||
href: '/download',
|
||||
},
|
||||
{ key: 'pricing', href: '/#pricing' },
|
||||
{ key: 'how_it_works', href: '/#how-it-works' },
|
||||
{ key: 'memory', href: '/#memory' },
|
||||
];
|
||||
|
||||
const RESEARCH_LINKS: readonly FooterLink[] = [
|
||||
{ key: 'methodology', href: '/docs/methodology' },
|
||||
{
|
||||
key: 'benchmarks',
|
||||
href: 'https://github.com/marolinik/hive-mind',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
key: 'hive_mind',
|
||||
href: 'https://github.com/marolinik/hive-mind',
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
const COMPANY_LINKS: readonly FooterLink[] = [
|
||||
{ key: 'about_egzakta', href: 'https://egzakta.com', external: true },
|
||||
{ key: 'kvark', href: 'https://www.kvark.ai', external: true },
|
||||
{ key: 'contact', href: 'mailto:hello@egzakta.com' },
|
||||
];
|
||||
|
||||
const LEGAL_LINKS: readonly FooterLink[] = [
|
||||
{ key: 'terms', href: '/terms' },
|
||||
{ key: 'privacy', href: '/privacy' },
|
||||
{ key: 'cookies', href: '/cookies' },
|
||||
{ key: 'eu_ai_act', href: '/eu-ai-act' },
|
||||
{
|
||||
key: 'apache',
|
||||
href: 'https://github.com/marolinik/hive-mind/blob/master/LICENSE',
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
const COLUMN_DEFS = [
|
||||
{ ns: 'product', links: PRODUCT_LINKS },
|
||||
{ ns: 'research', links: RESEARCH_LINKS },
|
||||
{ ns: 'company', links: COMPANY_LINKS },
|
||||
{ ns: 'legal', links: LEGAL_LINKS },
|
||||
] as const;
|
||||
|
||||
export default async function Footer() {
|
||||
const t = await getTranslations('landing.footer');
|
||||
|
||||
return (
|
||||
<footer id="footer" className={styles.footer}>
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.brandBlock}>
|
||||
<BrandMark withWordmark />
|
||||
<p className={styles.brandDescription}>{t('brand.description')}</p>
|
||||
<p className={styles.attribution}>{t('brand.attribution')}</p>
|
||||
</div>
|
||||
|
||||
{COLUMN_DEFS.map((col) => (
|
||||
<div key={col.ns}>
|
||||
<h3 className={styles.columnTitle}>{t(`columns.${col.ns}.title`)}</h3>
|
||||
<ul className={styles.columnList}>
|
||||
{col.links.map((l) => (
|
||||
<li key={l.key}>
|
||||
<a
|
||||
href={l.href}
|
||||
{...(l.external
|
||||
? { target: '_blank', rel: 'noopener noreferrer' }
|
||||
: null)}
|
||||
className={styles.columnLink}
|
||||
>
|
||||
{t(`columns.${col.ns}.links.${l.key}`)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.baseline}>
|
||||
<span>{t('base_line.left')}</span>
|
||||
<span className={styles.baselineRight}>{t('base_line.right')}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
160
apps/www/app/_components/Hero.module.css
Normal file
160
apps/www/app/_components/Hero.module.css
Normal file
@@ -0,0 +1,160 @@
|
||||
.section {
|
||||
position: relative;
|
||||
padding: 168px var(--gutter) 104px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
top: -240px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 900px;
|
||||
height: 560px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(233, 165, 44, 0.09) 0%,
|
||||
rgba(233, 165, 44, 0.03) 45%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: relative;
|
||||
max-width: var(--container-wide);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 10fr) minmax(0, 9fr);
|
||||
gap: 64px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: var(--text-display);
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.headlineEmphasis {
|
||||
color: var(--honey-400);
|
||||
}
|
||||
|
||||
.subhead {
|
||||
font-size: var(--text-lead);
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
margin-bottom: 36px;
|
||||
max-width: 34em;
|
||||
}
|
||||
|
||||
.ctaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.microcopy {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
row-gap: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.microItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.microDot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--honey-500);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.section {
|
||||
padding-top: 136px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 56px;
|
||||
}
|
||||
|
||||
.copy {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Round-7: one benchmark strip in the hero's dead bottom quarter.
|
||||
Wave R Lane E: the 86.49% number is lifted to a flagship stat chip
|
||||
(larger mono, honey-wash lozenge) so the leaderboard claim carries weight
|
||||
near the CTAs; the rest stays quiet supporting mono copy. */
|
||||
.proofStrip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
max-width: 40em;
|
||||
color: var(--honey-500);
|
||||
text-decoration: none;
|
||||
}
|
||||
.proofStat {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--honey-wash);
|
||||
border: 1px solid var(--honey-line);
|
||||
font-family: var(--mono);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--honey-400);
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.proofLabel {
|
||||
font-family: var(--mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.proofStrip:hover .proofStat {
|
||||
border-color: var(--honey-500);
|
||||
background: rgba(233, 165, 44, 0.16);
|
||||
}
|
||||
.proofStrip:hover .proofLabel {
|
||||
color: var(--hive-300);
|
||||
}
|
||||
71
apps/www/app/_components/Hero.tsx
Normal file
71
apps/www/app/_components/Hero.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import DownloadCTA from './DownloadCTA';
|
||||
import HeroVisual from './HeroVisual';
|
||||
import styles from './Hero.module.css';
|
||||
|
||||
const MICROCOPY_KEYS = [
|
||||
'microcopy_free',
|
||||
'microcopy_platforms',
|
||||
'microcopy_oss',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Hero — single committed headline (the A/B variant infra was removed with
|
||||
* the 2026-07 rebuild). Two columns: positioning copy left, the memory-core
|
||||
* window visual right. All strings under `landing.hero.*`.
|
||||
*/
|
||||
export default async function Hero() {
|
||||
const t = await getTranslations('landing.hero');
|
||||
|
||||
return (
|
||||
<section id="hero" className={`${styles.section} honeycomb-bg`}>
|
||||
<div className={styles.glow} aria-hidden="true" />
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.copy}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
|
||||
<h1 className={styles.headline}>
|
||||
{t('headline_lead')}{' '}
|
||||
<span className={styles.headlineEmphasis}>
|
||||
{t('headline_emphasis')}
|
||||
</span>
|
||||
.
|
||||
</h1>
|
||||
|
||||
<p className={styles.subhead}>{t('subhead')}</p>
|
||||
|
||||
<div className={styles.ctaRow}>
|
||||
<DownloadCTA section="hero" variant="primary" />
|
||||
<a href="#proof" className="btn btn-ghost">
|
||||
{t('cta_secondary')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul className={styles.microcopy}>
|
||||
{MICROCOPY_KEYS.map((key) => (
|
||||
<li key={key} className={styles.microItem}>
|
||||
<span aria-hidden="true" className={styles.microDot} />
|
||||
{t(key)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Round-7: the hero's dead bottom quarter carries the product's
|
||||
strongest proof — one benchmark strip, linking to #proof.
|
||||
Wave R Lane E: the 86.49% number is lifted to a flagship stat
|
||||
(larger mono, honey) so the leaderboard claim carries visual
|
||||
weight near the CTAs; the rest stays quiet supporting copy. */}
|
||||
<a href="#proof" className={styles.proofStrip}>
|
||||
<span className={styles.proofStat}>{t('proof_stat')}</span>
|
||||
<span className={styles.proofLabel}>{t('proof_strip')}</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className={styles.visual}>
|
||||
<HeroVisual />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
137
apps/www/app/_components/HeroVisual.module.css
Normal file
137
apps/www/app/_components/HeroVisual.module.css
Normal file
@@ -0,0 +1,137 @@
|
||||
.window {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--hive-900) 0%, var(--hive-950) 100%);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titleBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: rgba(31, 26, 18, 0.6);
|
||||
}
|
||||
|
||||
.trafficDots {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.trafficDot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--hive-600);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.titleText {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.titleBadge {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--honey-400);
|
||||
border: 1px solid var(--honey-line);
|
||||
background: var(--honey-wash);
|
||||
border-radius: 999px;
|
||||
padding: 2px 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.footerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Edge pulse: honey energy flowing between memory and models. */
|
||||
.edge {
|
||||
stroke: var(--line-strong);
|
||||
stroke-width: 1.2;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.edgePulse {
|
||||
stroke: var(--honey-500);
|
||||
stroke-width: 1.4;
|
||||
fill: none;
|
||||
stroke-dasharray: 10 110;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.8;
|
||||
animation: edge-flow 3.2s linear infinite;
|
||||
}
|
||||
|
||||
.edgePulse2 {
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.edgePulse3 {
|
||||
animation-delay: 1.6s;
|
||||
}
|
||||
|
||||
.edgePulse4 {
|
||||
animation-delay: 2.4s;
|
||||
}
|
||||
|
||||
@keyframes edge-flow {
|
||||
from {
|
||||
stroke-dashoffset: 120;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.coreGlow {
|
||||
animation: core-breathe 4s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
|
||||
@keyframes core-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.edgePulse {
|
||||
animation: none;
|
||||
stroke-dasharray: none;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.coreGlow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
160
apps/www/app/_components/HeroVisual.tsx
Normal file
160
apps/www/app/_components/HeroVisual.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import styles from './HeroVisual.module.css';
|
||||
|
||||
const CHIPS = [
|
||||
{ key: 'claude', x: 28, y: 36, flow: 'out' },
|
||||
{ key: 'gpt', x: 374, y: 36, flow: 'out' },
|
||||
{ key: 'qwen', x: 28, y: 258, flow: 'in' },
|
||||
{ key: 'gemini', x: 374, y: 258, flow: 'out' },
|
||||
] as const;
|
||||
|
||||
const CHIP_W = 118;
|
||||
const CHIP_H = 44;
|
||||
|
||||
/**
|
||||
* Hero visualization — a desktop-app window (Waggle ships as a Tauri
|
||||
* binary) framing the true architecture: one local memory core serving
|
||||
* four model chips. Edges pulse honey; the `qwen · local` edge flows
|
||||
* INTO the core (commit) while the others flow out (recall).
|
||||
*
|
||||
* Server component: the animation is pure CSS (HeroVisual.module.css),
|
||||
* disabled under `prefers-reduced-motion`. No invented numbers anywhere —
|
||||
* labels name real subsystems only.
|
||||
*/
|
||||
export default async function HeroVisual() {
|
||||
const t = await getTranslations('landing.hero_visual');
|
||||
|
||||
return (
|
||||
<figure className={styles.window} aria-label={t('aria_label')}>
|
||||
<div className={styles.titleBar}>
|
||||
<span className={styles.trafficDots} aria-hidden="true">
|
||||
<span className={styles.trafficDot} />
|
||||
<span className={styles.trafficDot} />
|
||||
<span className={styles.trafficDot} />
|
||||
</span>
|
||||
<span className={styles.titleText}>{t('window_title')}</span>
|
||||
<span className={styles.titleBadge}>{t('window_badge')}</span>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className={styles.svg}
|
||||
viewBox="0 0 520 340"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="hv-core-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="rgba(233,165,44,0.22)" />
|
||||
<stop offset="70%" stopColor="rgba(233,165,44,0.05)" />
|
||||
<stop offset="100%" stopColor="rgba(233,165,44,0)" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* Edges: base line + honey pulse. Recall edges run core → chip;
|
||||
the commit edge (qwen · local) runs chip → core. */}
|
||||
<path className={styles.edge} d="M 232,140 C 200,112 182,84 150,62" />
|
||||
<path
|
||||
className={`${styles.edgePulse}`}
|
||||
d="M 232,140 C 200,112 182,84 150,62"
|
||||
/>
|
||||
|
||||
<path className={styles.edge} d="M 288,140 C 320,112 338,84 370,62" />
|
||||
<path
|
||||
className={`${styles.edgePulse} ${styles.edgePulse2}`}
|
||||
d="M 288,140 C 320,112 338,84 370,62"
|
||||
/>
|
||||
|
||||
<path className={styles.edge} d="M 150,278 C 182,256 200,224 232,196" />
|
||||
<path
|
||||
className={`${styles.edgePulse} ${styles.edgePulse3}`}
|
||||
d="M 150,278 C 182,256 200,224 232,196"
|
||||
/>
|
||||
|
||||
<path className={styles.edge} d="M 288,196 C 320,224 338,256 370,278" />
|
||||
<path
|
||||
className={`${styles.edgePulse} ${styles.edgePulse4}`}
|
||||
d="M 288,196 C 320,224 338,256 370,278"
|
||||
/>
|
||||
|
||||
{/* Memory core */}
|
||||
<circle
|
||||
className={styles.coreGlow}
|
||||
cx="260"
|
||||
cy="168"
|
||||
r="78"
|
||||
fill="url(#hv-core-glow)"
|
||||
/>
|
||||
<path
|
||||
d="M260 112 L308.5 140 L308.5 196 L260 224 L211.5 196 L211.5 140 Z"
|
||||
fill="rgba(31, 26, 18, 0.85)"
|
||||
stroke="var(--honey-500)"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<text
|
||||
x="260"
|
||||
y="165"
|
||||
textAnchor="middle"
|
||||
fill="var(--hive-50)"
|
||||
fontSize="13.5"
|
||||
fontWeight="600"
|
||||
fontFamily="var(--sans)"
|
||||
>
|
||||
{t('center_label')}
|
||||
</text>
|
||||
<text
|
||||
x="260"
|
||||
y="184"
|
||||
textAnchor="middle"
|
||||
fill="var(--text-muted)"
|
||||
fontSize="9"
|
||||
fontFamily="var(--mono)"
|
||||
>
|
||||
{t('center_sublabel')}
|
||||
</text>
|
||||
|
||||
{/* Model chips */}
|
||||
{CHIPS.map((chip) => (
|
||||
<g key={chip.key}>
|
||||
<rect
|
||||
x={chip.x}
|
||||
y={chip.y}
|
||||
width={CHIP_W}
|
||||
height={CHIP_H}
|
||||
rx="10"
|
||||
fill="var(--surface)"
|
||||
stroke="var(--line-strong)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={chip.x + 14}
|
||||
y={chip.y + 19}
|
||||
fill="var(--hive-100)"
|
||||
fontSize="12"
|
||||
fontWeight="600"
|
||||
fontFamily="var(--mono)"
|
||||
>
|
||||
{t(`chips.${chip.key}_primary`)}
|
||||
</text>
|
||||
<text
|
||||
x={chip.x + 14}
|
||||
y={chip.y + 33}
|
||||
fill={chip.flow === 'in' ? 'var(--honey-400)' : 'var(--text-muted)'}
|
||||
fontSize="9"
|
||||
fontFamily="var(--mono)"
|
||||
>
|
||||
{chip.flow === 'in' ? '↑ ' : '↓ '}
|
||||
{t(`chips.${chip.key}_sub`)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<div className={styles.footerBar}>
|
||||
<span>{t('footer_left')}</span>
|
||||
<span>{t('footer_right')}</span>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
93
apps/www/app/_components/HowItWorks.module.css
Normal file
93
apps/www/app/_components/HowItWorks.module.css
Normal file
@@ -0,0 +1,93 @@
|
||||
.header {
|
||||
max-width: 720px;
|
||||
margin: 0 auto 52px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
padding: 30px 28px 32px;
|
||||
border-radius: var(--r-lg);
|
||||
background: linear-gradient(180deg, var(--hive-900) 0%, var(--hive-950) 100%);
|
||||
border: 1px solid var(--line-soft);
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.step:hover {
|
||||
border-color: var(--honey-line);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-honey);
|
||||
}
|
||||
|
||||
/* Flow connector: a honey chevron seated in the gap before every step after
|
||||
the first, so the three cards read as one sequence (01 → 02 → 03).
|
||||
Each step is wrapped in a <Reveal> div, so the chevron hangs off the reveal
|
||||
wrapper (the actual grid cell), not the inner .step card. */
|
||||
.steps > :global(.reveal) {
|
||||
position: relative;
|
||||
}
|
||||
.steps > :global(.reveal) + :global(.reveal)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 51px;
|
||||
left: -22px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: no-repeat center / contain
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M7 4l6 6-6 6' stroke='%23e9a52c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
opacity: 0.75;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.stepNumber {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--honey-500);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--honey-line);
|
||||
background: var(--honey-wash);
|
||||
box-shadow: 0 0 20px rgba(233, 165, 44, 0.12);
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-size: var(--text-h3);
|
||||
font-weight: 600;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stepBody {
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.65;
|
||||
color: var(--hive-300);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.steps {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Chevrons point downward when the flow stacks vertically. */
|
||||
.steps > :global(.reveal) + :global(.reveal)::before {
|
||||
top: -21px;
|
||||
left: 30px;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
44
apps/www/app/_components/HowItWorks.tsx
Normal file
44
apps/www/app/_components/HowItWorks.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './HowItWorks.module.css';
|
||||
|
||||
const STEPS = ['step_01', 'step_02', 'step_03'] as const;
|
||||
|
||||
/**
|
||||
* Three-step product story: import history → work in workspaces → memory
|
||||
* compounds. Strings under `landing.how_it_works.*`.
|
||||
*/
|
||||
export default async function HowItWorks() {
|
||||
const t = await getTranslations('landing.how_it_works');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="how-it-works"
|
||||
className="section"
|
||||
aria-labelledby="how-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="how-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className={styles.steps}>
|
||||
{STEPS.map((step, i) => (
|
||||
<Reveal key={step} delay={(i + 1) as 1 | 2 | 3}>
|
||||
<div className={styles.step}>
|
||||
<span className={styles.stepNumber} aria-hidden="true">
|
||||
{t(`${step}.number`)}
|
||||
</span>
|
||||
<h3 className={styles.stepTitle}>{t(`${step}.title`)}</h3>
|
||||
<p className={styles.stepBody}>{t(`${step}.body`)}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
89
apps/www/app/_components/MemoryDiagram.module.css
Normal file
89
apps/www/app/_components/MemoryDiagram.module.css
Normal file
@@ -0,0 +1,89 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 72px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.7;
|
||||
color: var(--hive-300);
|
||||
margin-top: 20px;
|
||||
max-width: 34em;
|
||||
}
|
||||
|
||||
.chips {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 28px 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--hive-200);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
border-radius: 999px;
|
||||
padding: 7px 14px;
|
||||
}
|
||||
|
||||
/* ── Pipeline card ── */
|
||||
.pipeline {
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--hive-900) 0%, var(--hive-950) 100%);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
padding: 32px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.node {
|
||||
border-radius: var(--r);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--surface);
|
||||
padding: 14px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nodeAccent {
|
||||
border-color: var(--honey-line);
|
||||
background: var(--honey-wash);
|
||||
}
|
||||
|
||||
.nodeTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-50);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nodeSub {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.connector {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
color: var(--honey-600);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 48px;
|
||||
}
|
||||
}
|
||||
80
apps/www/app/_components/MemoryDiagram.tsx
Normal file
80
apps/www/app/_components/MemoryDiagram.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './MemoryDiagram.module.css';
|
||||
|
||||
const CHIP_KEYS = ['local', 'provenance', 'erasure'] as const;
|
||||
|
||||
/**
|
||||
* "Under the hood" — the real memory pipeline, named after the actual
|
||||
* subsystems (frames → hybrid search → knowledge graph → any model).
|
||||
* Copy under `landing.memory.*`; the diagram is semantic HTML so it
|
||||
* stacks naturally and needs no JS.
|
||||
*/
|
||||
export default async function MemoryDiagram() {
|
||||
const t = await getTranslations('landing.memory');
|
||||
|
||||
return (
|
||||
<section id="memory" className="section" aria-labelledby="memory-heading">
|
||||
<div className="container">
|
||||
<div className={styles.grid}>
|
||||
<div>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="memory-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
<p className={styles.body}>{t('body')}</p>
|
||||
<ul className={styles.chips}>
|
||||
{CHIP_KEYS.map((key) => (
|
||||
<li key={key} className={styles.chip}>
|
||||
{t(`chips.${key}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
<div className={styles.pipeline} role="img" aria-label={t('headline')}>
|
||||
<div className={styles.node}>
|
||||
<span className={styles.nodeTitle}>{t('diagram.input')}</span>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={styles.node}>
|
||||
<span className={styles.nodeTitle}>{t('diagram.frames')}</span>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={styles.node}>
|
||||
<span className={styles.nodeTitle}>{t('diagram.search')}</span>
|
||||
<span className={styles.nodeSub}>{t('diagram.search_sub')}</span>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={styles.node}>
|
||||
<span className={styles.nodeTitle}>{t('diagram.graph')}</span>
|
||||
<span className={styles.nodeSub}>{t('diagram.graph_sub')}</span>
|
||||
</div>
|
||||
<Arrow />
|
||||
<div className={`${styles.node} ${styles.nodeAccent}`}>
|
||||
<span className={styles.nodeTitle}>{t('diagram.output')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow() {
|
||||
return (
|
||||
<span className={styles.connector} aria-hidden="true">
|
||||
<svg width="12" height="14" viewBox="0 0 12 14" fill="none">
|
||||
<path
|
||||
d="M6 0 V11 M2 8 L6 12 L10 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
145
apps/www/app/_components/Navbar.module.css
Normal file
145
apps/www/app/_components/Navbar.module.css
Normal file
@@ -0,0 +1,145 @@
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
transition: background 0.25s ease, border-color 0.25s ease,
|
||||
backdrop-filter 0.25s ease;
|
||||
border-bottom: 1px solid transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.headerScrolled {
|
||||
background: rgba(14, 12, 7, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom-color: var(--line-soft);
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-width: var(--container-wide);
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--gutter);
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--text-small);
|
||||
font-weight: 500;
|
||||
color: var(--hive-300);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--hive-50);
|
||||
background: rgba(236, 227, 208, 0.05);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.signIn {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--text-small);
|
||||
font-weight: 500;
|
||||
color: var(--hive-200);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
.signIn:hover {
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
display: none;
|
||||
background: none;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--hive-100);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobilePanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.signIn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.mobilePanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px var(--gutter) 20px;
|
||||
background: rgba(14, 12, 7, 0.96);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.mobileLink {
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--hive-200);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mobileLink:hover {
|
||||
color: var(--hive-50);
|
||||
background: rgba(236, 227, 208, 0.05);
|
||||
}
|
||||
|
||||
.mobileActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px 0;
|
||||
}
|
||||
}
|
||||
153
apps/www/app/_components/Navbar.tsx
Normal file
153
apps/www/app/_components/Navbar.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { SignInButton, UserButton, Show } from '@clerk/nextjs';
|
||||
import BrandMark from './BrandMark';
|
||||
import DownloadCTA from './DownloadCTA';
|
||||
import styles from './Navbar.module.css';
|
||||
|
||||
/* Absolute-path anchors so the navbar also works from /privacy, /terms,
|
||||
and the other legal pages that render this chrome. */
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/#how-it-works', key: 'how_it_works' },
|
||||
{ href: '/#memory', key: 'memory' },
|
||||
{ href: '/#proof', key: 'benchmark' },
|
||||
{ href: '/#open-source', key: 'open_source' },
|
||||
{ href: '/#pricing', key: 'pricing' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fixed top navigation. Transparent over the hero, gains a blurred backdrop
|
||||
* + hairline border after a small scroll. Collapses to a menu button below
|
||||
* 860px; the mobile panel reuses the same anchor list.
|
||||
*
|
||||
* Stays a Client Component for scroll-aware backdrop + menu state. All
|
||||
* strings under `landing.navbar.*`.
|
||||
*/
|
||||
export default function Navbar() {
|
||||
const t = useTranslations('landing.navbar');
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const headerClass = [
|
||||
styles.header,
|
||||
scrolled || open ? styles.headerScrolled : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<header className={headerClass}>
|
||||
<div className={styles.inner}>
|
||||
<a href="/" className={styles.brand} aria-label={t('aria.home')}>
|
||||
<BrandMark withWordmark />
|
||||
</a>
|
||||
|
||||
<nav className={styles.nav} aria-label={t('aria.primary')}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a key={item.key} href={item.href} className={styles.navLink}>
|
||||
{t(`links.${item.key}`)}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Show when="signed-out">
|
||||
<SignInButton mode="modal">
|
||||
<button type="button" className={styles.signIn}>
|
||||
{t('ctas.sign_in')}
|
||||
</button>
|
||||
</SignInButton>
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<UserButton />
|
||||
</Show>
|
||||
<DownloadCTA section="navbar" size="small">
|
||||
{t('ctas.download')}
|
||||
</DownloadCTA>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuButton}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label={t('aria.toggle_menu')}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<MenuIcon open={open} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
className={styles.mobilePanel}
|
||||
aria-label={t('aria.primary')}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className={styles.mobileLink}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t(`links.${item.key}`)}
|
||||
</a>
|
||||
))}
|
||||
<div className={styles.mobileActions}>
|
||||
<Show when="signed-out">
|
||||
<SignInButton mode="modal">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.signIn}
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
{t('ctas.sign_in')}
|
||||
</button>
|
||||
</SignInButton>
|
||||
</Show>
|
||||
<Show when="signed-in">
|
||||
<UserButton />
|
||||
</Show>
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuIcon({ open }: { readonly open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
d="M4 4 L14 14 M14 4 L4 14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
d="M2 5 H16 M2 9 H16 M2 13 H16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
87
apps/www/app/_components/OpenSource.module.css
Normal file
87
apps/www/app/_components/OpenSource.module.css
Normal file
@@ -0,0 +1,87 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 72px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: var(--text-body);
|
||||
line-height: 1.7;
|
||||
color: var(--hive-300);
|
||||
margin-top: 20px;
|
||||
max-width: 34em;
|
||||
}
|
||||
|
||||
.ctaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
border-radius: var(--r-lg);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--hive-950);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminalBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
background: rgba(31, 26, 18, 0.6);
|
||||
}
|
||||
|
||||
.terminalDots {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.terminalDot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--hive-600);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.terminalTitle {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.terminalBody {
|
||||
margin: 0;
|
||||
padding: 22px 22px 26px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.9;
|
||||
color: var(--hive-200);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: var(--honey-500);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.comment {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.output {
|
||||
color: var(--healthy);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 48px;
|
||||
}
|
||||
}
|
||||
88
apps/www/app/_components/OpenSource.tsx
Normal file
88
apps/www/app/_components/OpenSource.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './OpenSource.module.css';
|
||||
|
||||
const HIVE_MIND_URL = 'https://github.com/marolinik/hive-mind';
|
||||
const NPM_URL = 'https://www.npmjs.com/package/@hive-mind/core';
|
||||
|
||||
/**
|
||||
* Open-source section: hive-mind (the memory substrate) with a terminal
|
||||
* showing the offline benchmark reproduction path. Command + output are
|
||||
* verbatim from the OSS repo (benchmarks/locomo/artifacts/w4-n1540/
|
||||
* recount.mjs — verified against the local clone 2026-07-03); if that
|
||||
* script moves, update this terminal. Strings under `landing.open_source.*`.
|
||||
*/
|
||||
export default async function OpenSource() {
|
||||
const t = await getTranslations('landing.open_source');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="open-source"
|
||||
className="section"
|
||||
aria-labelledby="oss-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<div className={styles.grid}>
|
||||
<div>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="oss-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
<p className={styles.body}>{t('body')}</p>
|
||||
<div className={styles.ctaRow}>
|
||||
<a
|
||||
href={HIVE_MIND_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('cta_github')}
|
||||
</a>
|
||||
<a
|
||||
href={NPM_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
{t('cta_npm')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
<div className={styles.terminal} aria-label={t('terminal_aria')}>
|
||||
<div className={styles.terminalBar}>
|
||||
<span className={styles.terminalDots} aria-hidden="true">
|
||||
<span className={styles.terminalDot} />
|
||||
<span className={styles.terminalDot} />
|
||||
<span className={styles.terminalDot} />
|
||||
</span>
|
||||
<span className={styles.terminalTitle}>
|
||||
{t('terminal_title')}
|
||||
</span>
|
||||
</div>
|
||||
<pre className={styles.terminalBody}>
|
||||
<code>
|
||||
<span className={styles.prompt}>$ </span>
|
||||
git clone https://github.com/marolinik/hive-mind{'\n'}
|
||||
<span className={styles.prompt}>$ </span>
|
||||
cd hive-mind/benchmarks/locomo{'\n'}
|
||||
<span className={styles.comment}>
|
||||
# recount the committed judgments — no API keys, no network
|
||||
</span>
|
||||
{'\n'}
|
||||
<span className={styles.prompt}>$ </span>
|
||||
node artifacts/w4-n1540/recount.mjs{'\n'}
|
||||
<span className={styles.output}>
|
||||
overall 1332/1540 = 86.49%{'\n'}
|
||||
RECOUNT OK — committed judgments reproduce 86.49%.
|
||||
</span>
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
190
apps/www/app/_components/Pricing.module.css
Normal file
190
apps/www/app/_components/Pricing.module.css
Normal file
@@ -0,0 +1,190 @@
|
||||
.header {
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--hive-900);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 999px;
|
||||
width: fit-content;
|
||||
margin: 0 auto 56px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: var(--sans);
|
||||
background: transparent;
|
||||
color: var(--hive-300);
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.toggleActive {
|
||||
background: var(--surface-2);
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
.savePill {
|
||||
color: var(--honey-400);
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.checkoutNotice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
max-width: 680px;
|
||||
margin: -28px auto 36px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--honey-line);
|
||||
border-radius: var(--r-md);
|
||||
background: color-mix(in srgb, var(--honey-500) 10%, transparent);
|
||||
color: var(--hive-100);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noticeLink {
|
||||
color: var(--honey-300);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.noticeLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
max-width: 1080px;
|
||||
margin: 0 auto 40px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.tierCard {
|
||||
position: relative;
|
||||
border-radius: var(--r-lg);
|
||||
padding: 30px 28px 28px;
|
||||
background: var(--hive-900);
|
||||
border: 1px solid var(--line-soft);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tierCardHighlighted {
|
||||
background: var(--hive-850);
|
||||
border-color: var(--honey-line);
|
||||
box-shadow: var(--shadow-honey);
|
||||
}
|
||||
|
||||
/* Enterprise/KVARK slot: present but visually subordinate to Solo/Team. */
|
||||
.tierCardQuiet {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.enterpriseBody {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
margin: 0 0 26px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--honey-500);
|
||||
color: var(--hive-950);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tierName {
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
.tierTagline {
|
||||
font-size: 13px;
|
||||
color: var(--hive-300);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
.priceNote {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
margin-bottom: 22px;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.bullets {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 26px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.bullet {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--hive-200);
|
||||
margin-bottom: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bulletIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
color: var(--honey-500);
|
||||
}
|
||||
|
||||
.tierCta {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 460px;
|
||||
}
|
||||
}
|
||||
253
apps/www/app/_components/Pricing.tsx
Normal file
253
apps/www/app/_components/Pricing.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import DownloadCTA from './DownloadCTA';
|
||||
import { emit, events } from '../_lib/event-taxonomy';
|
||||
import styles from './Pricing.module.css';
|
||||
|
||||
type BillingPeriod = 'monthly' | 'annual';
|
||||
type TierId = 'SOLO' | 'TEAMS';
|
||||
|
||||
interface TierDef {
|
||||
readonly id: TierId;
|
||||
readonly nsKey: 'solo' | 'teams';
|
||||
readonly highlighted: boolean;
|
||||
readonly bulletKeys: readonly string[];
|
||||
readonly ctaType: 'download' | 'stripe';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier content mirrors `packages/shared/src/tiers.ts` (the canonical tier
|
||||
* system): SOLO is free forever with full memory + Harvest, unlimited
|
||||
* workspaces, marketplace, all connectors, and BYO cloud models; TEAMS adds
|
||||
* shared workspaces, WaggleDance, and governance. No bullets beyond what
|
||||
* tiers.ts encodes. The 15-day Team trial lives in the section subhead —
|
||||
* it applies to every install, so listing it as a Solo feature misreads.
|
||||
*/
|
||||
const TIER_DEFS: readonly TierDef[] = [
|
||||
{
|
||||
id: 'SOLO',
|
||||
nsKey: 'solo',
|
||||
highlighted: false,
|
||||
bulletKeys: [
|
||||
'bullet_memory',
|
||||
'bullet_workspaces',
|
||||
'bullet_marketplace',
|
||||
'bullet_models',
|
||||
'bullet_skills',
|
||||
],
|
||||
ctaType: 'download',
|
||||
},
|
||||
{
|
||||
id: 'TEAMS',
|
||||
nsKey: 'teams',
|
||||
highlighted: true,
|
||||
bulletKeys: [
|
||||
'bullet_everything_solo',
|
||||
'bullet_shared',
|
||||
'bullet_dance',
|
||||
'bullet_governance',
|
||||
],
|
||||
ctaType: 'stripe',
|
||||
},
|
||||
];
|
||||
|
||||
const STRIPE_ENDPOINT =
|
||||
(process.env.NEXT_PUBLIC_API_URL ?? '').replace(/\/$/, '') +
|
||||
'/api/stripe/checkout';
|
||||
|
||||
const KVARK_URL = 'https://www.kvark.ai';
|
||||
|
||||
export default function Pricing() {
|
||||
const t = useTranslations('landing.pricing');
|
||||
const [billing, setBilling] = useState<BillingPeriod>('monthly');
|
||||
const [checkoutCancelled, setCheckoutCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
setCheckoutCancelled(params.get('checkout') === 'cancelled');
|
||||
}, []);
|
||||
|
||||
const handleBillingChange = useCallback((mode: BillingPeriod) => {
|
||||
setBilling(mode);
|
||||
emit({ name: events.pricingBillingToggle, properties: { mode } });
|
||||
}, []);
|
||||
|
||||
const handleStripeCtaClick = useCallback(
|
||||
(tier: TierId) => {
|
||||
emit({
|
||||
name: events.ctaClick,
|
||||
properties: { section: 'pricing', tier, billing },
|
||||
});
|
||||
},
|
||||
[billing],
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="pricing" className="section" aria-labelledby="pricing-heading">
|
||||
<div className="container-wide">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="pricing-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
<p className="section-lead">{t('subhead')}</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('toggle.aria_group')}
|
||||
className={styles.toggleRow}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBillingChange('monthly')}
|
||||
className={[
|
||||
styles.toggle,
|
||||
billing === 'monthly' ? styles.toggleActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={billing === 'monthly'}
|
||||
>
|
||||
{t('toggle.monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBillingChange('annual')}
|
||||
className={[
|
||||
styles.toggle,
|
||||
billing === 'annual' ? styles.toggleActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={billing === 'annual'}
|
||||
>
|
||||
{t('toggle.annual')}
|
||||
<span className={styles.savePill}>{t('toggle.save_pill')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{checkoutCancelled ? (
|
||||
<div role="status" className={styles.checkoutNotice}>
|
||||
<span>{t('notices.cancelled')}</span>
|
||||
<a
|
||||
href={`${STRIPE_ENDPOINT}?tier=teams&billing=${billing}`}
|
||||
onClick={() => handleStripeCtaClick('TEAMS')}
|
||||
className={styles.noticeLink}
|
||||
>
|
||||
{t('notices.retry')}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.grid}>
|
||||
{TIER_DEFS.map((tier) => {
|
||||
const priceKey =
|
||||
billing === 'monthly' ? 'price_monthly' : 'price_annual';
|
||||
const cardClass = [
|
||||
styles.tierCard,
|
||||
tier.highlighted ? styles.tierCardHighlighted : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<div key={tier.id} className={cardClass}>
|
||||
{tier.highlighted ? (
|
||||
<span className={styles.badge}>{t('popular_badge')}</span>
|
||||
) : null}
|
||||
<h3 className={styles.tierName}>
|
||||
{t(`tiers.${tier.nsKey}.name`)}
|
||||
</h3>
|
||||
<p className={styles.tierTagline}>
|
||||
{t(`tiers.${tier.nsKey}.tagline`)}
|
||||
</p>
|
||||
|
||||
<p className={styles.price}>
|
||||
{t(`tiers.${tier.nsKey}.${priceKey}`)}
|
||||
</p>
|
||||
<p className={styles.priceNote}>
|
||||
{t(`tiers.${tier.nsKey}.note`)}
|
||||
</p>
|
||||
|
||||
<ul className={styles.bullets}>
|
||||
{tier.bulletKeys.map((bk) => (
|
||||
<li key={bk} className={styles.bullet}>
|
||||
<CheckIcon />
|
||||
<span>{t(`tiers.${tier.nsKey}.${bk}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{tier.ctaType === 'download' ? (
|
||||
<DownloadCTA
|
||||
section="solo-tier"
|
||||
variant={tier.highlighted ? 'primary' : 'ghost'}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={`${STRIPE_ENDPOINT}?tier=${tier.id.toLowerCase()}&billing=${billing}`}
|
||||
onClick={() => handleStripeCtaClick(tier.id)}
|
||||
className={[
|
||||
'btn',
|
||||
tier.highlighted ? 'btn-primary' : 'btn-ghost',
|
||||
styles.tierCta,
|
||||
].join(' ')}
|
||||
>
|
||||
{t(`tiers.${tier.nsKey}.cta`)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Enterprise = KVARK sovereign deployment. A quieter third card
|
||||
(description, no checklist) so the grid fills its row without
|
||||
inventing a tier — pricing stays consultative. */}
|
||||
<div className={[styles.tierCard, styles.tierCardQuiet].join(' ')}>
|
||||
<h3 className={styles.tierName}>{t('enterprise.name')}</h3>
|
||||
<p className={styles.tierTagline}>{t('enterprise.tagline')}</p>
|
||||
|
||||
<p className={styles.price}>{t('enterprise.price')}</p>
|
||||
<p className={styles.priceNote}>{t('enterprise.note')}</p>
|
||||
|
||||
<p className={styles.enterpriseBody}>{t('enterprise.text')}</p>
|
||||
|
||||
<a
|
||||
href={KVARK_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={['btn', 'btn-ghost', styles.tierCta].join(' ')}
|
||||
>
|
||||
{t('enterprise.cta')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className={styles.bulletIcon}
|
||||
>
|
||||
<path
|
||||
d="M3 8.5 L6.5 12 L13 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
58
apps/www/app/_components/ProblemTurn.module.css
Normal file
58
apps/www/app/_components/ProblemTurn.module.css
Normal file
@@ -0,0 +1,58 @@
|
||||
.header {
|
||||
max-width: 720px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.beat {
|
||||
padding: 26px 26px 28px;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--hive-900);
|
||||
border: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.beatIndex {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.beatTitle {
|
||||
font-size: var(--text-h3);
|
||||
font-weight: 600;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.beatBody {
|
||||
font-size: var(--text-small);
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
}
|
||||
|
||||
.turn {
|
||||
border-left: 2px solid var(--honey-500);
|
||||
padding-left: 24px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.turnText {
|
||||
font-size: clamp(1.125rem, 2vw, 1.375rem);
|
||||
line-height: 1.55;
|
||||
font-weight: 500;
|
||||
color: var(--hive-100);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
51
apps/www/app/_components/ProblemTurn.tsx
Normal file
51
apps/www/app/_components/ProblemTurn.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './ProblemTurn.module.css';
|
||||
|
||||
const BEATS = ['reintroduce', 'tabs', 'compound'] as const;
|
||||
|
||||
/**
|
||||
* The problem statement ("every AI session starts from zero") in three
|
||||
* beats, then the turn — Waggle's opposite bet — as a pull-quote with a
|
||||
* honey rule. Strings under `landing.problem.*`.
|
||||
*/
|
||||
export default async function ProblemTurn() {
|
||||
const t = await getTranslations('landing.problem');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="problem"
|
||||
className="section"
|
||||
aria-labelledby="problem-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="problem-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{BEATS.map((beat, i) => (
|
||||
<Reveal key={beat} delay={(i + 1) as 1 | 2 | 3}>
|
||||
<div className={styles.beat}>
|
||||
<span className={styles.beatIndex} aria-hidden="true">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h3 className={styles.beatTitle}>{t(`beats.${beat}.title`)}</h3>
|
||||
<p className={styles.beatBody}>{t(`beats.${beat}.body`)}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Reveal>
|
||||
<div className={styles.turn}>
|
||||
<p className={styles.turnText}>{t('turn')}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
111
apps/www/app/_components/ProofBand.module.css
Normal file
111
apps/www/app/_components/ProofBand.module.css
Normal file
@@ -0,0 +1,111 @@
|
||||
.band {
|
||||
background: var(--hive-900);
|
||||
border-top: 1px solid var(--line-soft);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.header {
|
||||
max-width: 760px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
max-width: 820px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr 72px;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.system {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.systemName {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-100);
|
||||
}
|
||||
|
||||
.systemDetail {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.track {
|
||||
display: block;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: var(--hive-800);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
background: var(--hive-600);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.barHighlight {
|
||||
background: linear-gradient(90deg, var(--honey-600) 0%, var(--honey-500) 100%);
|
||||
box-shadow: var(--shadow-honey);
|
||||
}
|
||||
|
||||
.score {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-100);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.scoreHighlight {
|
||||
color: var(--honey-400);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-muted);
|
||||
max-width: 720px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.ctaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr 56px;
|
||||
grid-template-rows: auto auto;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.system {
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.track {
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
84
apps/www/app/_components/ProofBand.tsx
Normal file
84
apps/www/app/_components/ProofBand.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import { LOCOMO_BARS } from '../_data/proof-points';
|
||||
import styles from './ProofBand.module.css';
|
||||
|
||||
const HIVE_MIND_URL = 'https://github.com/marolinik/hive-mind';
|
||||
|
||||
/**
|
||||
* Benchmark proof band. The chart is honest by construction: bar widths are
|
||||
* raw scores on a 0–100 axis (no truncated baseline), the protocol footnote
|
||||
* names the judge, N, and significance, and both CTAs lead to verification
|
||||
* paths (methodology page, reproducible repo). Data from `_data/proof-points`.
|
||||
*/
|
||||
export default async function ProofBand() {
|
||||
const t = await getTranslations('landing.proof');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="proof"
|
||||
className={`section ${styles.band}`}
|
||||
aria-labelledby="proof-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="proof-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
<p className="section-lead">{t('body')}</p>
|
||||
</header>
|
||||
|
||||
<Reveal>
|
||||
<div className={styles.chart} role="img" aria-label={t('chart_aria')}>
|
||||
{LOCOMO_BARS.map((bar) => (
|
||||
<div key={bar.id} className={styles.row}>
|
||||
<span className={styles.system}>
|
||||
<span className={styles.systemName}>{bar.system}</span>
|
||||
<span className={styles.systemDetail}>{bar.detail}</span>
|
||||
</span>
|
||||
<span className={styles.track}>
|
||||
<span
|
||||
className={[
|
||||
styles.bar,
|
||||
bar.highlight ? styles.barHighlight : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ width: `${bar.score}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={[
|
||||
styles.score,
|
||||
bar.highlight ? styles.scoreHighlight : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{bar.score.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<p className={styles.footnote}>{t('footnote')}</p>
|
||||
|
||||
<div className={styles.ctaRow}>
|
||||
<a href="/docs/methodology" className="btn btn-ghost">
|
||||
{t('cta_methodology')}
|
||||
</a>
|
||||
<a
|
||||
href={HIVE_MIND_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
{t('cta_reproduce')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
86
apps/www/app/_components/Reveal.tsx
Normal file
86
apps/www/app/_components/Reveal.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface RevealProps {
|
||||
readonly children: ReactNode;
|
||||
/** Stagger slot 1–5 → transition-delay 60ms steps (see globals.css). */
|
||||
readonly delay?: 1 | 2 | 3 | 4 | 5;
|
||||
readonly as?: 'div' | 'section' | 'li' | 'span';
|
||||
readonly className?: string;
|
||||
readonly style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll-reveal wrapper: fades + lifts children in when they enter the
|
||||
* viewport. Purely presentational — content is in the DOM at SSR (SEO-safe)
|
||||
* and `prefers-reduced-motion` disables the effect entirely via globals.css.
|
||||
*/
|
||||
export default function Reveal({
|
||||
children,
|
||||
delay,
|
||||
as: Tag = 'div',
|
||||
className,
|
||||
style,
|
||||
}: RevealProps) {
|
||||
const nodeRef = useRef<HTMLElement | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const setNode = useCallback((node: HTMLElement | null) => {
|
||||
nodeRef.current = node;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const node = nodeRef.current;
|
||||
if (!node) return;
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -10% 0px', threshold: 0.1 },
|
||||
);
|
||||
observer.observe(node);
|
||||
// Safety net: if a section is never scrolled into view — a crawler, a
|
||||
// social-preview renderer, or a full-page screenshot that paints without
|
||||
// scrolling — the observer never fires and the content would stay stuck at
|
||||
// opacity:0. Reveal it anyway shortly after mount so no section is ever a
|
||||
// headline floating in an empty void. Real users scrolling normally still
|
||||
// trip the observer first and get the entrance animation per section.
|
||||
const fallback = window.setTimeout(() => setVisible(true), 900);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.clearTimeout(fallback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const classes = [
|
||||
'reveal',
|
||||
visible ? 'is-visible' : '',
|
||||
delay ? `reveal-d${delay}` : '',
|
||||
className ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<Tag ref={setNode} className={classes} style={style}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
47
apps/www/app/_components/SovereigntyBand.module.css
Normal file
47
apps/www/app/_components/SovereigntyBand.module.css
Normal file
@@ -0,0 +1,47 @@
|
||||
.band {
|
||||
background: var(--hive-900);
|
||||
border-top: 1px solid var(--line-soft);
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
|
||||
.header {
|
||||
max-width: 720px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 32px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.item {
|
||||
border-top: 2px solid var(--honey-600);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-50);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.body {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
46
apps/www/app/_components/SovereigntyBand.tsx
Normal file
46
apps/www/app/_components/SovereigntyBand.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Reveal from './Reveal';
|
||||
import styles from './SovereigntyBand.module.css';
|
||||
|
||||
const ITEMS = ['device', 'egress', 'erasure', 'injection'] as const;
|
||||
|
||||
/**
|
||||
* Data-sovereignty band: four specific, verifiable statements about where
|
||||
* data lives and what leaves the machine. Trust through specificity — no
|
||||
* compliance badges we don't hold. Strings under `landing.sovereignty.*`.
|
||||
*/
|
||||
export default async function SovereigntyBand() {
|
||||
const t = await getTranslations('landing.sovereignty');
|
||||
|
||||
return (
|
||||
<section
|
||||
id="trust"
|
||||
className={`section ${styles.band}`}
|
||||
aria-labelledby="trust-heading"
|
||||
>
|
||||
<div className="container">
|
||||
<header className={styles.header}>
|
||||
<p className="eyebrow">{t('eyebrow')}</p>
|
||||
<h2 id="trust-heading" className="section-headline">
|
||||
{t('headline')}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{ITEMS.map((item, i) => (
|
||||
<Reveal key={item} delay={((i % 4) + 1) as 1 | 2 | 3 | 4}>
|
||||
<div className={styles.item}>
|
||||
<h3 className={styles.title}>{t(`items.${item}.title`)}</h3>
|
||||
<p className={styles.body}>{t(`items.${item}.body`)}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a href="/eu-ai-act" className="btn btn-ghost btn-small">
|
||||
{t('cta')}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
125
apps/www/app/_data/personas.ts
Normal file
125
apps/www/app/_data/personas.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Canonical 13-persona data source for Waggle bee mascots.
|
||||
*
|
||||
* Copy is ratified per `PM-Waggle-OS/decisions/2026-04-22-personas-card-copy-locked.md`.
|
||||
* Do NOT rewrite or paraphrase in consumer components — import from this module only.
|
||||
*
|
||||
* Asset paths are root-absolute (`/brand/...`) per Sesija D §1 ratification (c):
|
||||
* the legacy Vite-relative form (`brand/...` resolved against `base: '/waggle/'`)
|
||||
* was a GitHub Pages staging artifact, not production. waggle-os.ai does not
|
||||
* apply a path prefix.
|
||||
*/
|
||||
|
||||
export type PersonaSlug =
|
||||
| 'hunter'
|
||||
| 'researcher'
|
||||
| 'analyst'
|
||||
| 'connector'
|
||||
| 'architect'
|
||||
| 'builder'
|
||||
| 'writer'
|
||||
| 'orchestrator'
|
||||
| 'marketer'
|
||||
| 'team'
|
||||
| 'celebrating'
|
||||
| 'confused'
|
||||
| 'sleeping';
|
||||
|
||||
export interface Persona {
|
||||
/** Canonical slug used for keying, asset lookup, analytics callbacks. */
|
||||
readonly slug: PersonaSlug;
|
||||
/** Display title, e.g. "The Hunter". Verbatim from locked copy. */
|
||||
readonly title: string;
|
||||
/** One-line JTBD copy below the title. Verbatim from locked copy. */
|
||||
readonly role: string;
|
||||
/** Accessible image label — "Waggle {title} bee mascot". */
|
||||
readonly alt: string;
|
||||
/** Root-absolute asset path served from /public. */
|
||||
readonly imagePath: string;
|
||||
/** 1-13 canonical reading order (input → process → output → meta). */
|
||||
readonly order: number;
|
||||
}
|
||||
|
||||
function buildPersona(
|
||||
slug: PersonaSlug,
|
||||
title: string,
|
||||
role: string,
|
||||
order: number,
|
||||
): Persona {
|
||||
return {
|
||||
slug,
|
||||
title,
|
||||
role,
|
||||
alt: `Waggle ${title} bee mascot`,
|
||||
imagePath: `/brand/bee-${slug}-dark.png`,
|
||||
order,
|
||||
};
|
||||
}
|
||||
|
||||
export const personas: readonly Persona[] = [
|
||||
buildPersona('hunter', 'The Hunter', 'Finds the source you forgot you saved.', 1),
|
||||
buildPersona('researcher', 'The Researcher', 'Goes deep and brings back a verdict.', 2),
|
||||
buildPersona('analyst', 'The Analyst', 'Sees the shape of what keeps repeating.', 3),
|
||||
buildPersona(
|
||||
'connector',
|
||||
'The Connector',
|
||||
"Links yesterday's thought to tomorrow's decision.",
|
||||
4,
|
||||
),
|
||||
buildPersona(
|
||||
'architect',
|
||||
'The Architect',
|
||||
'Gives chaos a structure you can reason about.',
|
||||
5,
|
||||
),
|
||||
buildPersona('builder', 'The Builder', 'Turns a spec into something that ships.', 6),
|
||||
buildPersona('writer', 'The Writer', 'Shapes the story the memory wants to tell.', 7),
|
||||
buildPersona(
|
||||
'orchestrator',
|
||||
'The Orchestrator',
|
||||
'Coordinates the agents, tools, and memory.',
|
||||
8,
|
||||
),
|
||||
buildPersona(
|
||||
'marketer',
|
||||
'The Marketer',
|
||||
'Translates what you do into what matters to them.',
|
||||
9,
|
||||
),
|
||||
buildPersona('team', 'The Team', 'Many hands, one hive.', 10),
|
||||
buildPersona(
|
||||
'celebrating',
|
||||
'The Milestone',
|
||||
'Marks the moment when the work compounds.',
|
||||
11,
|
||||
),
|
||||
buildPersona(
|
||||
'confused',
|
||||
'The Signal',
|
||||
'Raises a flag when memory and reality disagree.',
|
||||
12,
|
||||
),
|
||||
buildPersona(
|
||||
'sleeping',
|
||||
'The Night Shift',
|
||||
'Consolidates while you rest — the hive never closes.',
|
||||
13,
|
||||
),
|
||||
] as const;
|
||||
|
||||
/** O(1) lookup by slug. */
|
||||
export const personaBySlug: Readonly<Record<PersonaSlug, Persona>> = Object.freeze(
|
||||
personas.reduce(
|
||||
(acc, persona) => {
|
||||
acc[persona.slug] = persona;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<PersonaSlug, Persona>,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Path to the hex-texture PNG used as filler-tile background and as
|
||||
* placeholder canvas when a persona asset fails to load.
|
||||
*/
|
||||
export const HEX_TEXTURE_PATH = '/brand/hex-texture-dark.png';
|
||||
48
apps/www/app/_data/proof-points.ts
Normal file
48
apps/www/app/_data/proof-points.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Verified LoCoMo benchmark data for the proof band (86.49 SOTA arc,
|
||||
* 2026-06; the earlier 87.66 figure was withdrawn 2026-07-01 as
|
||||
* unreproducible — never surface it).
|
||||
*
|
||||
* Protocol: LoCoMo, N=1,540, GPT-4.1-mini as answerer AND judge — the prior
|
||||
* leader's (Memori) published protocol, reproduced in-harness before
|
||||
* comparing. Waggle (hive-mind substrate) 86.49% vs Memori 81.95%
|
||||
* (+4.54pp, z=4.64, p<10⁻⁵); Mem0 re-measured under the same protocol:
|
||||
* 73.96%. Single-hop: 92.27%. Sources: benchmarks/results/locomo-sota-2026-06/
|
||||
* and docs/methodology.md §0. Numbers MUST match those files. Do not
|
||||
* fabricate; do not rescale chart axes away from zero.
|
||||
*/
|
||||
|
||||
export interface BenchmarkBar {
|
||||
readonly id: string;
|
||||
readonly system: string;
|
||||
readonly detail: string;
|
||||
readonly score: number;
|
||||
readonly highlight: boolean;
|
||||
}
|
||||
|
||||
export const LOCOMO_BARS: readonly BenchmarkBar[] = Object.freeze([
|
||||
{
|
||||
id: 'waggle',
|
||||
system: 'Waggle (hive-mind)',
|
||||
detail: 'open source · runs locally',
|
||||
score: 86.49,
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
id: 'memori',
|
||||
system: 'Memori',
|
||||
detail: 'prior state of the art',
|
||||
score: 81.95,
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
id: 'mem0',
|
||||
system: 'Mem0',
|
||||
detail: 're-measured, same protocol',
|
||||
score: 73.96,
|
||||
highlight: false,
|
||||
},
|
||||
]);
|
||||
|
||||
/** Secondary verified stat: single-hop recall. */
|
||||
export const SINGLE_HOP_SCORE = 92.27;
|
||||
24
apps/www/app/_lib/event-taxonomy.ts
Normal file
24
apps/www/app/_lib/event-taxonomy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Stub event taxonomy for the v3.2 landing per amendment §2.1.
|
||||
*
|
||||
* Logs to console in development; no-op in production. Replace with a real
|
||||
* analytics provider (PostHog / Plausible) in Phase 2.
|
||||
*/
|
||||
|
||||
export interface LandingEvent {
|
||||
readonly name: string;
|
||||
readonly properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const events = {
|
||||
pageView: 'landing.page_view',
|
||||
sectionVisible: 'landing.section_visible',
|
||||
ctaClick: 'landing.cta_click',
|
||||
pricingBillingToggle: 'landing.pricing.billing_toggle.changed',
|
||||
} as const;
|
||||
|
||||
export function emit(event: LandingEvent): void {
|
||||
if (process.env.NODE_ENV === 'production') return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[landing.event]', event.name, event.properties ?? {});
|
||||
}
|
||||
15
apps/www/app/_lib/os-detection.ts
Normal file
15
apps/www/app/_lib/os-detection.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type OSId = 'macOS' | 'Windows' | 'Linux';
|
||||
|
||||
/**
|
||||
* Best-effort desktop OS detection from a User-Agent string.
|
||||
*
|
||||
* Mobile/tablet visitors return null so download CTAs stay generic instead of
|
||||
* promising a desktop installer for the wrong platform.
|
||||
*/
|
||||
export function detectOSFromUserAgent(ua: string): OSId | null {
|
||||
if (/iPhone|iPad|iPod|Android|Mobile|Tablet/i.test(ua)) return null;
|
||||
if (/Mac/i.test(ua)) return 'macOS';
|
||||
if (/Windows/i.test(ua)) return 'Windows';
|
||||
if (/Linux/i.test(ua)) return 'Linux';
|
||||
return null;
|
||||
}
|
||||
39
apps/www/app/account/page.tsx
Normal file
39
apps/www/app/account/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { UserProfile } from '@clerk/nextjs';
|
||||
|
||||
/**
|
||||
* Account management page (Sesija E §5.2).
|
||||
*
|
||||
* Server component: gates access via `await auth()`. Unauthenticated users
|
||||
* are redirected to /sign-in before any Clerk component mounts. Authenticated
|
||||
* users see Clerk's <UserProfile>, which handles email/password updates,
|
||||
* connected accounts, sessions, security settings.
|
||||
*
|
||||
* Stripe Customer ID surfaced in user.publicMetadata is set by §5.3 webhooks
|
||||
* (apps/www/app/api/webhooks/clerk/route.ts) on the user.created event.
|
||||
*
|
||||
* Appearance is inherited from <ClerkProvider> in app/layout.tsx (Hive DS).
|
||||
*/
|
||||
export default async function AccountPage() {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
redirect('/sign-in');
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={pageStyle}>
|
||||
<UserProfile />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pageStyle: CSSProperties = {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '96px 24px 48px',
|
||||
background: 'var(--hive-950, #0e0c07)',
|
||||
};
|
||||
284
apps/www/app/api/stripe/checkout/route.ts
Normal file
284
apps/www/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth, clerkClient } from '@clerk/nextjs/server';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
/**
|
||||
* /api/stripe/checkout — Stripe Checkout Session creator with Clerk linkage.
|
||||
*
|
||||
* Sesija E §5.3 Phase C: lazy-create Stripe Customer pattern. On first
|
||||
* paid checkout for a user we create the Customer, store its id in
|
||||
* `Clerk.user.publicMetadata.stripeCustomerId`, and reuse it forever after.
|
||||
* Customer.metadata.clerkUserId mirrors the linkage in the other direction
|
||||
* so subscription webhooks can map back to a Clerk user.
|
||||
*
|
||||
* GET ?tier=teams&billing=monthly|annual
|
||||
* - Canonical entrypoint (per §5.3 brief). Used by Clerk SignUp's
|
||||
* `forceRedirectUrl` after sign-up completion.
|
||||
* - Returns 303 redirect to the Stripe Checkout URL on success.
|
||||
* - Signed-out: 303 to /sign-in with redirect_url back to this endpoint.
|
||||
*
|
||||
* POST { tier, billingPeriod }
|
||||
* - Backward-compat shim for older clients. Pricing.tsx now uses the
|
||||
* canonical GET flow.
|
||||
* - Returns JSON { url } on success or { message } on error.
|
||||
* - Signed-out: 401 JSON { message }.
|
||||
*/
|
||||
|
||||
// New checkout is TEAMS-only (Solo is free). Legacy 'pro' is rejected here;
|
||||
// legacy pro subscription webhooks are still honored in the webhook route.
|
||||
type Tier = 'teams';
|
||||
type Billing = 'monthly' | 'annual';
|
||||
|
||||
const TIERS: readonly Tier[] = ['teams'];
|
||||
const BILLINGS: readonly Billing[] = ['monthly', 'annual'];
|
||||
|
||||
interface ClerkPublicMetadata {
|
||||
readonly stripeCustomerId?: string;
|
||||
readonly subscriptionTier?: Tier;
|
||||
readonly subscriptionStatus?:
|
||||
| 'active'
|
||||
| 'past_due'
|
||||
| 'canceled'
|
||||
| 'trialing'
|
||||
| 'incomplete';
|
||||
}
|
||||
|
||||
interface CheckoutSuccess {
|
||||
readonly kind: 'success';
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
interface CheckoutAuthRedirect {
|
||||
readonly kind: 'auth_required';
|
||||
readonly signInUrl: string;
|
||||
}
|
||||
|
||||
interface CheckoutFailure {
|
||||
readonly kind: 'failure';
|
||||
readonly status: number;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
type CheckoutResult = CheckoutSuccess | CheckoutAuthRedirect | CheckoutFailure;
|
||||
|
||||
function normalizeTier(value: unknown): Tier | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const lower = value.toLowerCase();
|
||||
return TIERS.includes(lower as Tier) ? (lower as Tier) : null;
|
||||
}
|
||||
|
||||
function normalizeBilling(value: unknown): Billing | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const lower = value.toLowerCase();
|
||||
return BILLINGS.includes(lower as Billing) ? (lower as Billing) : null;
|
||||
}
|
||||
|
||||
function isValidStripeKey(key: string | undefined): key is string {
|
||||
return (
|
||||
typeof key === 'string' &&
|
||||
(key.startsWith('sk_test_') || key.startsWith('sk_live_'))
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureStripeCustomer(
|
||||
userId: string,
|
||||
email: string | null,
|
||||
existingId: string | undefined,
|
||||
stripe: Stripe,
|
||||
): Promise<string> {
|
||||
if (existingId) return existingId;
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
email: email ?? undefined,
|
||||
metadata: { clerkUserId: userId },
|
||||
});
|
||||
|
||||
const cc = await clerkClient();
|
||||
await cc.users.updateUserMetadata(userId, {
|
||||
publicMetadata: { stripeCustomerId: customer.id } satisfies ClerkPublicMetadata,
|
||||
});
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
async function resolvePriceId(
|
||||
stripe: Stripe,
|
||||
tier: Tier,
|
||||
billing: Billing,
|
||||
): Promise<string | null> {
|
||||
// Prefer env-pinned IDs (zero round-trip). Fall back to lookup_key resolution
|
||||
// so the route still works in environments where price IDs aren't pinned.
|
||||
const envKey = `STRIPE_PRICE_${tier.toUpperCase()}_${billing.toUpperCase()}`;
|
||||
const pinned = process.env[envKey];
|
||||
if (pinned && pinned.startsWith('price_')) return pinned;
|
||||
|
||||
const lookupKey = `${tier}_${billing}`;
|
||||
const list = await stripe.prices.list({
|
||||
lookup_keys: [lookupKey],
|
||||
active: true,
|
||||
limit: 1,
|
||||
});
|
||||
return list.data[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function runCheckout(
|
||||
origin: string,
|
||||
tier: Tier,
|
||||
billing: Billing,
|
||||
): Promise<CheckoutResult> {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
const target = `/api/stripe/checkout?tier=${tier}&billing=${billing}`;
|
||||
return {
|
||||
kind: 'auth_required',
|
||||
signInUrl: `${origin}/sign-in?redirect_url=${encodeURIComponent(target)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!isValidStripeKey(secretKey)) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
status: 503,
|
||||
message:
|
||||
'Stripe checkout configuration required. Set STRIPE_SECRET_KEY in env (sk_test_* or sk_live_*).',
|
||||
};
|
||||
}
|
||||
|
||||
const stripe = new Stripe(secretKey);
|
||||
|
||||
const cc = await clerkClient();
|
||||
const user = await cc.users.getUser(userId);
|
||||
const publicMetadata = (user.publicMetadata ?? {}) as ClerkPublicMetadata;
|
||||
const email = user.primaryEmailAddress?.emailAddress ?? null;
|
||||
|
||||
const customerId = await ensureStripeCustomer(
|
||||
userId,
|
||||
email,
|
||||
publicMetadata.stripeCustomerId,
|
||||
stripe,
|
||||
);
|
||||
|
||||
const priceId = await resolvePriceId(stripe, tier, billing);
|
||||
if (!priceId) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
status: 503,
|
||||
message: `No active Stripe price found for ${tier}/${billing}. Set STRIPE_PRICE_${tier.toUpperCase()}_${billing.toUpperCase()} or assign lookup_key="${tier}_${billing}".`,
|
||||
};
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: customerId,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${origin}/account?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${origin}/?checkout=cancelled#pricing`,
|
||||
metadata: { clerkUserId: userId, tier, billing },
|
||||
subscription_data: {
|
||||
metadata: { clerkUserId: userId, tier, billing },
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
status: 500,
|
||||
message: 'Stripe session created without redirect URL',
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'success', url: session.url };
|
||||
}
|
||||
|
||||
function originOf(req: Request): string {
|
||||
const headerOrigin = req.headers.get('origin');
|
||||
if (headerOrigin) return headerOrigin;
|
||||
return new URL(req.url).origin;
|
||||
}
|
||||
|
||||
async function safeRunCheckout(
|
||||
origin: string,
|
||||
tier: Tier,
|
||||
billing: Billing,
|
||||
): Promise<CheckoutResult> {
|
||||
try {
|
||||
return await runCheckout(origin, tier, billing);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Stripe API error';
|
||||
return { kind: 'failure', status: 500, message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const tier = normalizeTier(url.searchParams.get('tier'));
|
||||
const billing = normalizeBilling(url.searchParams.get('billing'));
|
||||
if (!tier || !billing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message:
|
||||
'Invalid query. Expected ?tier=teams&billing=monthly|annual.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await safeRunCheckout(originOf(req), tier, billing);
|
||||
|
||||
switch (result.kind) {
|
||||
case 'success':
|
||||
return Response.redirect(result.url, 303);
|
||||
case 'auth_required':
|
||||
return Response.redirect(result.signInUrl, 303);
|
||||
case 'failure':
|
||||
return NextResponse.json(
|
||||
{ message: result.message },
|
||||
{ status: result.status },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<NextResponse> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ message: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return NextResponse.json({ message: 'Invalid body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const obj = body as Record<string, unknown>;
|
||||
const tier = normalizeTier(obj.tier);
|
||||
// Accept legacy field name `billingPeriod` from existing Pricing.tsx
|
||||
// alongside the new canonical `billing`.
|
||||
const billing = normalizeBilling(obj.billingPeriod ?? obj.billing);
|
||||
if (!tier || !billing) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message:
|
||||
'Invalid body. Expected { tier: "teams", billingPeriod: "monthly"|"annual" }.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await safeRunCheckout(originOf(req), tier, billing);
|
||||
|
||||
switch (result.kind) {
|
||||
case 'success':
|
||||
return NextResponse.json({ url: result.url });
|
||||
case 'auth_required':
|
||||
return NextResponse.json(
|
||||
{ message: 'Sign in required', signInUrl: result.signInUrl },
|
||||
{ status: 401 },
|
||||
);
|
||||
case 'failure':
|
||||
return NextResponse.json(
|
||||
{ message: result.message },
|
||||
{ status: result.status },
|
||||
);
|
||||
}
|
||||
}
|
||||
224
apps/www/app/api/webhooks/stripe/route.ts
Normal file
224
apps/www/app/api/webhooks/stripe/route.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { clerkClient } from '@clerk/nextjs/server';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
/**
|
||||
* /api/webhooks/stripe — Stripe webhook receiver (Sesija E §5.3 Phase C).
|
||||
*
|
||||
* Verifies signature against STRIPE_WEBHOOK_SECRET, then mirrors subscription
|
||||
* state from Stripe → Clerk user.publicMetadata so the Next.js app can gate
|
||||
* features on tier/status without round-tripping to Stripe on every request.
|
||||
*
|
||||
* Handled events:
|
||||
* checkout.session.completed → set tier + status='active'
|
||||
* customer.subscription.updated → map status, refresh tier
|
||||
* customer.subscription.deleted → status='canceled'
|
||||
*
|
||||
* Linkage strategy: every Checkout Session and Subscription gets
|
||||
* `metadata.clerkUserId` set by the checkout route. As a fallback, the Stripe
|
||||
* Customer also carries `metadata.clerkUserId` (set during lazy-create), so
|
||||
* subscription events that lack metadata can still resolve the user.
|
||||
*
|
||||
* Returns 200 quickly so Stripe's retry queue stays clean. Handler errors
|
||||
* surface as 500 (Stripe will retry up to its standard backoff schedule).
|
||||
*/
|
||||
|
||||
// New checkout is TEAMS-only, but this webhook still accepts legacy 'pro'
|
||||
// subscription events so existing subscribers keep getting status updates
|
||||
// (renewals, cancellations). Pro is no longer a sold tier — the app coerces
|
||||
// it to the free Solo label at display time (parseTier('PRO') → 'FREE').
|
||||
type Tier = 'pro' | 'teams';
|
||||
|
||||
interface ClerkPublicMetadata {
|
||||
readonly stripeCustomerId?: string;
|
||||
readonly subscriptionTier?: Tier;
|
||||
readonly subscriptionStatus?:
|
||||
| 'active'
|
||||
| 'past_due'
|
||||
| 'canceled'
|
||||
| 'trialing'
|
||||
| 'incomplete';
|
||||
}
|
||||
|
||||
function isValidStripeKey(key: string | undefined): key is string {
|
||||
return (
|
||||
typeof key === 'string' &&
|
||||
(key.startsWith('sk_test_') || key.startsWith('sk_live_'))
|
||||
);
|
||||
}
|
||||
|
||||
function isValidWebhookSecret(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && value.startsWith('whsec_');
|
||||
}
|
||||
|
||||
function asTier(value: unknown): Tier | undefined {
|
||||
return value === 'pro' || value === 'teams' ? value : undefined;
|
||||
}
|
||||
|
||||
function mapStatus(
|
||||
status: Stripe.Subscription.Status,
|
||||
): NonNullable<ClerkPublicMetadata['subscriptionStatus']> {
|
||||
// Collapse Stripe's 8 statuses into the 5 we expose to the app:
|
||||
// active | past_due | canceled | trialing | incomplete
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'past_due':
|
||||
case 'canceled':
|
||||
case 'trialing':
|
||||
case 'incomplete':
|
||||
return status;
|
||||
case 'unpaid':
|
||||
case 'incomplete_expired':
|
||||
return 'past_due';
|
||||
case 'paused':
|
||||
return 'canceled';
|
||||
default:
|
||||
return 'incomplete';
|
||||
}
|
||||
}
|
||||
|
||||
async function findClerkUserIdFromCustomer(
|
||||
customerId: string,
|
||||
stripe: Stripe,
|
||||
): Promise<string | null> {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
if ('deleted' in customer && customer.deleted) return null;
|
||||
const meta = (customer as Stripe.Customer).metadata;
|
||||
return meta?.clerkUserId ?? null;
|
||||
}
|
||||
|
||||
async function patchClerkPublicMetadata(
|
||||
userId: string,
|
||||
patch: Partial<ClerkPublicMetadata>,
|
||||
): Promise<void> {
|
||||
const cc = await clerkClient();
|
||||
const user = await cc.users.getUser(userId);
|
||||
const current = (user.publicMetadata ?? {}) as ClerkPublicMetadata;
|
||||
await cc.users.updateUserMetadata(userId, {
|
||||
publicMetadata: { ...current, ...patch } satisfies ClerkPublicMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCheckoutCompleted(
|
||||
event: Stripe.CheckoutSessionCompletedEvent,
|
||||
): Promise<void> {
|
||||
const session = event.data.object;
|
||||
const userId = session.metadata?.clerkUserId;
|
||||
const tier = asTier(session.metadata?.tier);
|
||||
// R1-002 (webhook path): only grant once payment has settled.
|
||||
// checkout.session.completed also fires for unpaid/async sessions.
|
||||
const paid =
|
||||
session.payment_status === 'paid' ||
|
||||
session.payment_status === 'no_payment_required';
|
||||
if (!userId || !tier || !paid) return;
|
||||
|
||||
await patchClerkPublicMetadata(userId, {
|
||||
subscriptionTier: tier,
|
||||
subscriptionStatus: 'active',
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(
|
||||
event: Stripe.CustomerSubscriptionUpdatedEvent,
|
||||
stripe: Stripe,
|
||||
): Promise<void> {
|
||||
const sub = event.data.object;
|
||||
const userId =
|
||||
sub.metadata?.clerkUserId ??
|
||||
(typeof sub.customer === 'string'
|
||||
? await findClerkUserIdFromCustomer(sub.customer, stripe)
|
||||
: null);
|
||||
if (!userId) return;
|
||||
|
||||
const tier = asTier(sub.metadata?.tier);
|
||||
const patch: ClerkPublicMetadata = {
|
||||
subscriptionStatus: mapStatus(sub.status),
|
||||
...(tier ? { subscriptionTier: tier } : {}),
|
||||
};
|
||||
|
||||
await patchClerkPublicMetadata(userId, patch);
|
||||
}
|
||||
|
||||
async function handleSubscriptionDeleted(
|
||||
event: Stripe.CustomerSubscriptionDeletedEvent,
|
||||
stripe: Stripe,
|
||||
): Promise<void> {
|
||||
const sub = event.data.object;
|
||||
const userId =
|
||||
sub.metadata?.clerkUserId ??
|
||||
(typeof sub.customer === 'string'
|
||||
? await findClerkUserIdFromCustomer(sub.customer, stripe)
|
||||
: null);
|
||||
if (!userId) return;
|
||||
|
||||
await patchClerkPublicMetadata(userId, { subscriptionStatus: 'canceled' });
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<NextResponse> {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
if (!isValidStripeKey(secretKey)) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Stripe not configured (STRIPE_SECRET_KEY missing/invalid)' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!isValidWebhookSecret(webhookSecret)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message:
|
||||
'Webhook secret not configured (STRIPE_WEBHOOK_SECRET missing/invalid)',
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const sig = req.headers.get('stripe-signature');
|
||||
if (!sig) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Missing stripe-signature header' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const rawBody = await req.text();
|
||||
const stripe = new Stripe(secretKey);
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Invalid signature';
|
||||
return NextResponse.json(
|
||||
{ message: `Signature verification failed: ${msg}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await handleCheckoutCompleted(event);
|
||||
break;
|
||||
case 'customer.subscription.updated':
|
||||
await handleSubscriptionUpdated(event, stripe);
|
||||
break;
|
||||
case 'customer.subscription.deleted':
|
||||
await handleSubscriptionDeleted(event, stripe);
|
||||
break;
|
||||
default:
|
||||
// Unhandled events ack with 200 — Stripe will keep delivering them
|
||||
// even if we don't act, so just no-op rather than returning an error.
|
||||
break;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Handler error';
|
||||
return NextResponse.json(
|
||||
{ message: `Handler error: ${msg}`, eventType: event.type },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true, eventType: event.type });
|
||||
}
|
||||
38
apps/www/app/design/personas/page.tsx
Normal file
38
apps/www/app/design/personas/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from 'next';
|
||||
import BrandPersonasCard from '@/app/_components/BrandPersonasCard';
|
||||
|
||||
/**
|
||||
* Isolated preview route for the `BrandPersonasCard` landing variant.
|
||||
*
|
||||
* Served at `/design/personas` (Next.js App Router). No nav chrome.
|
||||
* Used for visual QA, hand-off to design, and as the canonical reference.
|
||||
* Discovery is URL-only — not linked from the main landing, robots blocked
|
||||
* via the `metadata.robots` export below.
|
||||
*
|
||||
* `BrandPersonasCard` is a Client Component (uses `useState`/`useCallback`),
|
||||
* but this page itself is a Server Component — Next.js App Router supports
|
||||
* server→client composition, and the metadata export requires a server
|
||||
* component context.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Waggle — Personas Preview',
|
||||
description:
|
||||
'Internal preview route for the BrandPersonasCard component. Not indexed.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function DesignPersonasPage() {
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: 'var(--hive-950, #0e0c07)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
<BrandPersonasCard variant="landing" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
BIN
apps/www/app/design/personas/preview-1024-placeholder.png
Normal file
BIN
apps/www/app/design/personas/preview-1024-placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 791 KiB |
BIN
apps/www/app/design/personas/preview-1024.png
Normal file
BIN
apps/www/app/design/personas/preview-1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 769 KiB |
253
apps/www/app/docs/methodology/page.tsx
Normal file
253
apps/www/app/docs/methodology/page.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import BrandMark from '../../_components/BrandMark';
|
||||
|
||||
/**
|
||||
* /docs/methodology — Day 0 Trust Band Card 4 link target per Path D landing
|
||||
* decoupling (PM 2026-05-02).
|
||||
*
|
||||
* Renders `docs/methodology.md` (committed at repo root, see SHA `7d1e0fc`)
|
||||
* via react-markdown + remark-gfm. The file is read at module-load time;
|
||||
* combined with `force-static`, the markdown is baked into the build output
|
||||
* — no per-request file I/O, no runtime fs dependency.
|
||||
*
|
||||
* Path resolution: `process.cwd()` at `next build` is `apps/www/`; going up
|
||||
* two levels (`../../docs/methodology.md`) hits the repo-root docs directory.
|
||||
*
|
||||
* Visual design per PM ratification: hive-950 background, hive-100 body
|
||||
* text, honey-500 link accent. No nav, no sidebar — single-page docs with
|
||||
* a back-to-landing link in the footer.
|
||||
*/
|
||||
const METHODOLOGY_MD = readFileSync(
|
||||
resolve(process.cwd(), '..', '..', 'docs', 'methodology.md'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Methodology — Waggle',
|
||||
description:
|
||||
"How Waggle's memory is evaluated: LoCoMo (N=1,540) under the prior leader's own protocol and judge (GPT-4.1-mini answerer+judge) — 86.49%, a new state of the art (+4.54pp over the prior best), reproducible offline.",
|
||||
alternates: { canonical: 'https://waggle-os.ai/docs/methodology' },
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
export default function MethodologyPage() {
|
||||
return (
|
||||
<main style={pageStyle}>
|
||||
<header style={headerStyle}>
|
||||
<a href="/" style={brandLinkStyle}>
|
||||
<BrandMark withWordmark />
|
||||
<span style={separatorStyle}>·</span>
|
||||
<span style={crumbStyle}>Methodology</span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<article style={articleStyle} className="methodology-prose">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{METHODOLOGY_MD}</ReactMarkdown>
|
||||
</article>
|
||||
|
||||
<footer style={footerStyle}>
|
||||
<a href="/" style={backLinkStyle}>
|
||||
← Back to Waggle
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<style>{scopedCss}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pageStyle: CSSProperties = {
|
||||
minHeight: '100vh',
|
||||
background: 'var(--hive-950, #0e0c07)',
|
||||
color: 'var(--hive-100, #ece3d0)',
|
||||
fontFamily: "var(--sans)",
|
||||
paddingTop: 24,
|
||||
paddingBottom: 48,
|
||||
};
|
||||
|
||||
const headerStyle: CSSProperties = {
|
||||
maxWidth: 800,
|
||||
margin: '0 auto 48px',
|
||||
padding: '0 24px',
|
||||
};
|
||||
|
||||
const brandLinkStyle: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
textDecoration: 'none',
|
||||
color: 'var(--hive-200, #d8cfba)',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const separatorStyle: CSSProperties = {
|
||||
color: 'var(--hive-400, #948a73)',
|
||||
};
|
||||
|
||||
const crumbStyle: CSSProperties = {
|
||||
color: 'var(--hive-300, #c8bfa9)',
|
||||
};
|
||||
|
||||
const articleStyle: CSSProperties = {
|
||||
maxWidth: 800,
|
||||
margin: '0 auto',
|
||||
padding: '0 24px',
|
||||
};
|
||||
|
||||
const footerStyle: CSSProperties = {
|
||||
maxWidth: 800,
|
||||
margin: '64px auto 0',
|
||||
padding: '24px',
|
||||
borderTop: '1px solid var(--hive-700, #272117)',
|
||||
textAlign: 'center',
|
||||
};
|
||||
|
||||
const backLinkStyle: CSSProperties = {
|
||||
display: 'inline-block',
|
||||
fontSize: 14,
|
||||
color: 'var(--honey-400, #f6c45a)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
/* Markdown prose styling — scoped to .methodology-prose */
|
||||
const scopedCss = `
|
||||
.methodology-prose {
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
color: var(--hive-100, #ece3d0);
|
||||
}
|
||||
.methodology-prose h1 {
|
||||
font-size: clamp(28px, 4vw, 36px);
|
||||
font-weight: 800;
|
||||
color: var(--hive-50, #f6f1e4);
|
||||
margin-top: 0;
|
||||
margin-bottom: 24px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.methodology-prose h2 {
|
||||
font-size: clamp(22px, 3vw, 26px);
|
||||
font-weight: 700;
|
||||
color: var(--hive-50, #f6f1e4);
|
||||
margin-top: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.methodology-prose h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-100, #ece3d0);
|
||||
margin-top: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.methodology-prose h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--hive-200, #d8cfba);
|
||||
margin-top: 24px;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.methodology-prose p {
|
||||
margin: 0 0 16px;
|
||||
color: var(--hive-200, #d8cfba);
|
||||
}
|
||||
.methodology-prose a {
|
||||
color: var(--honey-400, #f6c45a);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--honey-600, #c07e16);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.methodology-prose a:hover {
|
||||
color: var(--honey-300, #f9d27e);
|
||||
}
|
||||
.methodology-prose strong {
|
||||
color: var(--hive-50, #f6f1e4);
|
||||
font-weight: 600;
|
||||
}
|
||||
.methodology-prose em {
|
||||
color: var(--hive-100, #ece3d0);
|
||||
font-style: italic;
|
||||
}
|
||||
.methodology-prose ul,
|
||||
.methodology-prose ol {
|
||||
margin: 0 0 20px;
|
||||
padding-left: 24px;
|
||||
color: var(--hive-200, #d8cfba);
|
||||
}
|
||||
.methodology-prose li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.methodology-prose li > p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.methodology-prose code {
|
||||
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--hive-900, #14110b);
|
||||
color: var(--honey-300, #f9d27e);
|
||||
border: 1px solid var(--hive-800, #1f1a12);
|
||||
}
|
||||
.methodology-prose pre {
|
||||
margin: 16px 0 24px;
|
||||
padding: 16px 20px;
|
||||
background: var(--hive-900, #14110b);
|
||||
border: 1px solid var(--hive-700, #272117);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.methodology-prose pre code {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--hive-100, #ece3d0);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.methodology-prose blockquote {
|
||||
margin: 0 0 20px;
|
||||
padding: 4px 16px;
|
||||
border-left: 3px solid var(--honey-500, #e9a52c);
|
||||
background: rgba(233, 165, 44, 0.04);
|
||||
color: var(--hive-200, #d8cfba);
|
||||
font-style: italic;
|
||||
}
|
||||
.methodology-prose blockquote p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.methodology-prose hr {
|
||||
margin: 40px 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--hive-700, #272117);
|
||||
}
|
||||
.methodology-prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.methodology-prose th,
|
||||
.methodology-prose td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--hive-800, #1f1a12);
|
||||
}
|
||||
.methodology-prose th {
|
||||
background: var(--hive-850, #1a160f);
|
||||
color: var(--hive-100, #ece3d0);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--hive-700, #272117);
|
||||
}
|
||||
.methodology-prose td {
|
||||
color: var(--hive-200, #d8cfba);
|
||||
}
|
||||
`;
|
||||
115
apps/www/app/download/page.tsx
Normal file
115
apps/www/app/download/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Download Waggle',
|
||||
description:
|
||||
'Download status and installer availability for Waggle OS desktop.',
|
||||
};
|
||||
|
||||
const HEADING = 'Download Waggle';
|
||||
const STATUS =
|
||||
'Windows and macOS installers are being prepared for the signed public release.';
|
||||
const BODY =
|
||||
'Until those installers are published, you can inspect the source repository or contact Egzakta for early access.';
|
||||
const SOURCE_CTA = 'View source on GitHub';
|
||||
const CONTACT_CTA = 'Request installer access';
|
||||
|
||||
const SOURCE_URL = 'https://github.com/marolinik/waggle-os';
|
||||
const CONTACT_URL = 'mailto:hello@egzakta.com?subject=Waggle%20installer%20access';
|
||||
|
||||
export default function DownloadPage() {
|
||||
return (
|
||||
<main style={pageStyle}>
|
||||
<section style={sectionStyle} aria-labelledby="download-heading">
|
||||
<p style={eyebrowStyle}>Desktop app</p>
|
||||
<h1 id="download-heading" style={h1Style}>
|
||||
{HEADING}
|
||||
</h1>
|
||||
<p style={statusStyle}>{STATUS}</p>
|
||||
<p style={bodyStyle}>{BODY}</p>
|
||||
<div style={actionsStyle}>
|
||||
<a href={SOURCE_URL} style={primaryLinkStyle}>
|
||||
{SOURCE_CTA}
|
||||
</a>
|
||||
<a href={CONTACT_URL} style={secondaryLinkStyle}>
|
||||
{CONTACT_CTA}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pageStyle: CSSProperties = {
|
||||
minHeight: '100vh',
|
||||
padding: '120px 24px 80px',
|
||||
background: 'var(--page, #0e0c07)',
|
||||
color: 'var(--hive-100, #ece3d0)',
|
||||
};
|
||||
|
||||
const sectionStyle: CSSProperties = {
|
||||
maxWidth: 760,
|
||||
margin: '0 auto',
|
||||
};
|
||||
|
||||
const eyebrowStyle: CSSProperties = {
|
||||
color: 'var(--honey-400, #f6c45a)',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 16,
|
||||
};
|
||||
|
||||
const h1Style: CSSProperties = {
|
||||
color: 'var(--hive-50, #f6f1e4)',
|
||||
fontSize: 'clamp(36px, 7vw, 68px)',
|
||||
lineHeight: 1,
|
||||
margin: '0 0 24px',
|
||||
};
|
||||
|
||||
const statusStyle: CSSProperties = {
|
||||
color: 'var(--hive-100, #ece3d0)',
|
||||
fontSize: 'clamp(18px, 2.4vw, 24px)',
|
||||
lineHeight: 1.45,
|
||||
margin: '0 0 14px',
|
||||
maxWidth: 680,
|
||||
};
|
||||
|
||||
const bodyStyle: CSSProperties = {
|
||||
color: 'var(--hive-300, #948a73)',
|
||||
fontSize: 16,
|
||||
lineHeight: 1.7,
|
||||
margin: '0 0 32px',
|
||||
maxWidth: 640,
|
||||
};
|
||||
|
||||
const actionsStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
};
|
||||
|
||||
const baseLinkStyle: CSSProperties = {
|
||||
borderRadius: 8,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 44,
|
||||
padding: '0 18px',
|
||||
fontWeight: 700,
|
||||
textDecoration: 'none',
|
||||
};
|
||||
|
||||
const primaryLinkStyle: CSSProperties = {
|
||||
...baseLinkStyle,
|
||||
background: 'var(--honey-500, #e9a52c)',
|
||||
color: 'var(--hive-950, #0e0c07)',
|
||||
};
|
||||
|
||||
const secondaryLinkStyle: CSSProperties = {
|
||||
...baseLinkStyle,
|
||||
border: '1px solid var(--line-soft, rgba(236, 227, 208, 0.14))',
|
||||
color: 'var(--hive-100, #ece3d0)',
|
||||
};
|
||||
364
apps/www/app/globals.css
Normal file
364
apps/www/app/globals.css
Normal file
@@ -0,0 +1,364 @@
|
||||
/* ── Waggle Landing Page — Warm-Hive Design System ── */
|
||||
/* No Tailwind — pure CSS custom properties + CSS Modules per component. */
|
||||
/* Token VALUES are the warm-Hive palette (PR8), 1:1 with the product app. */
|
||||
/* Canonical source: docs/design_handoff_waggle_app/design-files/styles/waggle.css §7 */
|
||||
|
||||
:root {
|
||||
/* Warm graphite hive scale (replaces the cool blue-grey scale) */
|
||||
--hive-950: #0e0c07;
|
||||
--hive-900: #14110b;
|
||||
--hive-850: #1a160f;
|
||||
--hive-800: #1f1a12;
|
||||
--hive-700: #272117;
|
||||
--hive-600: #4a4030;
|
||||
--hive-500: #6b6250;
|
||||
--hive-400: #948a73;
|
||||
--hive-300: #c8bfa9;
|
||||
--hive-200: #d8cfba;
|
||||
--hive-100: #ece3d0;
|
||||
--hive-50: #f6f1e4;
|
||||
/* Honey accent (warmer, replaces the prior honey family) */
|
||||
--honey-600: #c07e16;
|
||||
--honey-500: #e9a52c;
|
||||
--honey-400: #f6c45a;
|
||||
--honey-300: #f9d27e;
|
||||
--honey-glow: rgba(233, 165, 44, 0.12);
|
||||
--honey-pulse: rgba(233, 165, 44, 0.06);
|
||||
--shadow-honey: 0 0 24px rgba(233,165,44,0.12), 0 0 4px rgba(233,165,44,0.08);
|
||||
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.55), 0 2px 4px rgba(0,0,0,0.4);
|
||||
|
||||
/* Named warm tokens (from waggle.css section 7) */
|
||||
--bg: #14110b;
|
||||
--bg-2: #1a160f;
|
||||
--surface: #1f1a12;
|
||||
--surface-2: #272117;
|
||||
--surface-3: #322a1d;
|
||||
--line: #38301f;
|
||||
--line-soft: #2a2417;
|
||||
--line-strong: #4a4030;
|
||||
--text: #f6f1e4;
|
||||
--text-2: #c8bfa9;
|
||||
--text-muted: #948a73;
|
||||
--text-dim: #6b6250;
|
||||
--honey: #e9a52c;
|
||||
--honey-bright: #f6c45a;
|
||||
--honey-deep: #c07e16;
|
||||
--honey-wash: rgba(233, 165, 44, 0.10);
|
||||
--honey-line: rgba(233, 165, 44, 0.28);
|
||||
--work: #7aa6d6;
|
||||
--intel: #b196dd;
|
||||
--healthy: #6cb78c;
|
||||
--attention: #e9a52c;
|
||||
--risk: #db8068;
|
||||
--status-ai: #b196dd;
|
||||
--status-healthy: #6cb78c;
|
||||
--r-sm: 8px;
|
||||
--r: 12px;
|
||||
--r-lg: 18px;
|
||||
--r-xl: 26px;
|
||||
--sans: var(--font-hanken), 'Hanken Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--mono: var(--font-mono), 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace;
|
||||
|
||||
/* ── Type scale (marketing foundation) ── */
|
||||
--text-display: clamp(2.5rem, 5.4vw, 4.25rem); /* hero h1 */
|
||||
--text-h2: clamp(1.75rem, 3.1vw, 2.5rem); /* section headline */
|
||||
--text-h3: clamp(1.125rem, 1.6vw, 1.3125rem); /* card / step titles */
|
||||
--text-lead: clamp(1.0625rem, 1.5vw, 1.1875rem); /* section subhead */
|
||||
--text-body: 1rem;
|
||||
--text-small: 0.875rem;
|
||||
--text-xs: 0.75rem;
|
||||
|
||||
/* ── Layout rhythm ── */
|
||||
--container: 1120px;
|
||||
--container-wide: 1200px;
|
||||
--section-pad: clamp(80px, 11vw, 144px);
|
||||
--gutter: 24px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--sans);
|
||||
background: var(--hive-950);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--hive-950);
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
/* ── Scrollbar — thin warm overlay (the stock Windows scrollbar reads as
|
||||
chrome on the hero). Standard properties cover Firefox + modern
|
||||
Chromium; the ::-webkit-* rules cover Safari + older Chromium. ── */
|
||||
html {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--hive-600) transparent;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track,
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--hive-600);
|
||||
border-radius: 999px;
|
||||
/* Transparent border + padding-box clip = a slimmer overlay-style thumb. */
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--hive-500);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--sans);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
h1 { letter-spacing: -0.03em; }
|
||||
|
||||
p { text-wrap: pretty; }
|
||||
|
||||
::selection {
|
||||
background: rgba(233, 165, 44, 0.28);
|
||||
color: var(--hive-50);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--honey-500);
|
||||
outline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Fixed 64px navbar: anchor-jump targets (/#pricing, /#personas, …) must
|
||||
land with their headline clear of the nav, not occluded under it. */
|
||||
[id] {
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
|
||||
/* ── Skip link (a11y) ── */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -48px;
|
||||
left: 16px;
|
||||
z-index: 200;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--honey-500);
|
||||
color: var(--hive-950);
|
||||
font-size: var(--text-small);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: top 0.15s ease;
|
||||
}
|
||||
.skip-link:focus-visible {
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
/* ── Shared layout primitives ── */
|
||||
.container {
|
||||
max-width: var(--container);
|
||||
margin: 0 auto;
|
||||
padding-left: var(--gutter);
|
||||
padding-right: var(--gutter);
|
||||
}
|
||||
.container-wide {
|
||||
max-width: var(--container-wide);
|
||||
margin: 0 auto;
|
||||
padding-left: var(--gutter);
|
||||
padding-right: var(--gutter);
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-top: var(--section-pad);
|
||||
padding-bottom: var(--section-pad);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--mono);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--honey-500);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-headline {
|
||||
font-size: var(--text-h2);
|
||||
font-weight: 700;
|
||||
line-height: 1.12;
|
||||
color: var(--hive-50);
|
||||
max-width: 22em;
|
||||
}
|
||||
|
||||
.section-lead {
|
||||
font-size: var(--text-lead);
|
||||
line-height: 1.6;
|
||||
color: var(--hive-300);
|
||||
max-width: 40em;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Machine-truth line: mono, muted — the signature device for verifiable facts */
|
||||
.mono-line {
|
||||
font-family: var(--mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 28px;
|
||||
border-radius: var(--r);
|
||||
font-family: var(--sans);
|
||||
font-size: var(--text-small);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s ease, border-color 0.15s ease,
|
||||
color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
.btn:active { transform: scale(0.97); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--honey-500);
|
||||
color: var(--hive-950);
|
||||
box-shadow: var(--shadow-honey);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--honey-400);
|
||||
box-shadow: 0 0 32px rgba(233, 165, 44, 0.2), 0 0 6px rgba(233, 165, 44, 0.12);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--hive-100);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--hive-500);
|
||||
background: rgba(236, 227, 208, 0.04);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--r-lg);
|
||||
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
/* ── Honeycomb Background ── */
|
||||
.honeycomb-bg {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='49' viewBox='0 0 28 49'%3E%3Cg fill-rule='evenodd'%3E%3Cg fill='%23e9a52c' fill-opacity='0.03'%3E%3Cpath d='M13.99 9.25l13 7.5v15l-13 7.5L1 31.75v-15l12.99-7.5zM3 17.9v12.7l10.99 6.34 11-6.35V17.9l-11-6.34L3 17.9zM0 15l12.98-7.5V0h-2v6.35L0 12.69v2.3zm0 18.5L12.98 41v8h-2v-6.85L0 35.81v-2.3zM15 0v7.5L27.99 15H28v-2.31h-.01L17 6.35V0h-2zm0 49v-8l12.99-7.5H28v2.31h-.01L17 42.15V49h-2z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@keyframes honey-pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 0.8; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes card-enter {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.float { animation: float 3s ease-in-out infinite; }
|
||||
.honey-pulse { animation: honey-pulse 3s ease-in-out infinite; }
|
||||
.card-enter { animation: card-enter 0.6s ease-out both; }
|
||||
|
||||
.card-enter-1 { animation-delay: 0.1s; }
|
||||
.card-enter-2 { animation-delay: 0.2s; }
|
||||
.card-enter-3 { animation-delay: 0.3s; }
|
||||
.card-enter-4 { animation-delay: 0.4s; }
|
||||
|
||||
/* ── Scroll reveal (used via <Reveal>) ──
|
||||
Gated behind html.js (set by an inline script in layout.tsx) so content
|
||||
stays fully visible when JavaScript is unavailable. */
|
||||
html.js .reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
transition: opacity 0.55s ease, transform 0.55s ease;
|
||||
}
|
||||
html.js .reveal.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.reveal-d1 { transition-delay: 0.06s; }
|
||||
.reveal-d2 { transition-delay: 0.12s; }
|
||||
.reveal-d3 { transition-delay: 0.18s; }
|
||||
.reveal-d4 { transition-delay: 0.24s; }
|
||||
.reveal-d5 { transition-delay: 0.3s; }
|
||||
|
||||
/* ── Card hover lift (legacy — kept for existing components) ── */
|
||||
.card-lift {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.card-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-honey);
|
||||
border-color: var(--honey-500) !important;
|
||||
}
|
||||
|
||||
/* ── Button press (legacy — kept for existing components) ── */
|
||||
.btn-press:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* ── Reduced motion: disable all non-essential animation ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
.float,
|
||||
.honey-pulse,
|
||||
.card-enter {
|
||||
animation: none;
|
||||
}
|
||||
html.js .reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
.card-lift,
|
||||
.btn,
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
5
apps/www/app/icon.svg
Normal file
5
apps/www/app/icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" fill="none">
|
||||
<rect width="64" height="64" rx="14" fill="#0e0c07"/>
|
||||
<path d="M32 10 L50.6 20.75 L50.6 42.25 L32 53 L13.4 42.25 L13.4 20.75 Z" stroke="#e9a52c" stroke-width="4" stroke-linejoin="round" fill="none"/>
|
||||
<path d="M32 24 L39.5 28.3 L39.5 36.9 L32 41.2 L24.5 36.9 L24.5 28.3 Z" fill="#f6c45a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 398 B |
246
apps/www/app/layout.tsx
Normal file
246
apps/www/app/layout.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Hanken_Grotesk, JetBrains_Mono } from 'next/font/google';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
import { ClerkProvider } from '@clerk/nextjs';
|
||||
import { dark } from '@clerk/themes';
|
||||
import './globals.css';
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Hive DS appearance applied globally to all Clerk components.
|
||||
* Values updated to the warm-Hive palette (PR8): hex literals reflect the
|
||||
* remapped scale (e.g. hive-950 = #0e0c07, hive-100 = #ece3d0, honey = #e9a52c).
|
||||
*
|
||||
* `baseTheme: dark` flips Clerk's element-level defaults (input borders,
|
||||
* disabled states, focus rings, hardcoded text shades) to dark-friendly
|
||||
* baselines. Without it, the `variables` block below only overrides the
|
||||
* colors that Clerk exposes as variables — anything baked into the
|
||||
* component CSS stays at light-theme defaults, producing the "dark text
|
||||
* on dark background" effect on `/sign-in` and `/sign-up`.
|
||||
*
|
||||
* Variables layered on top of `dark` paint Hive accent colors:
|
||||
* - colorPrimary → honey-500 (CTA + active states)
|
||||
* - colorBackground → hive-950 (page + modal backdrop)
|
||||
* - colorText → hive-100 (primary fg)
|
||||
* - colorInputBackground → hive-800 (input fields)
|
||||
* - colorTextSecondary → hive-300 (secondary fg, helper text)
|
||||
*
|
||||
* Inherited by <SignIn>, <SignUp>, <UserProfile>, <SignInButton> modal,
|
||||
* and <UserButton> popover. Per-component overrides are layered on top
|
||||
* via `appearance` prop only when needed.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
// NOTE: do NOT use `as const` here. Clerk's `Appearance` type is a wide
|
||||
// discriminated union; deeply-readonly literals from `as const` over-narrow
|
||||
// it and at least one Clerk version silently dropped the `baseTheme` field
|
||||
// when the prop value didn't match the expected mutable shape.
|
||||
const HIVE_CLERK_APPEARANCE = {
|
||||
baseTheme: dark,
|
||||
variables: {
|
||||
colorPrimary: '#e9a52c',
|
||||
colorBackground: '#0e0c07',
|
||||
colorText: '#ece3d0',
|
||||
colorTextSecondary: '#c8bfa9',
|
||||
colorInputBackground: '#1f1a12',
|
||||
colorInputText: '#ece3d0',
|
||||
colorNeutral: '#c8bfa9',
|
||||
borderRadius: '8px',
|
||||
fontFamily: "'Hanken Grotesk', system-ui, sans-serif",
|
||||
},
|
||||
// Belt-and-braces element-level overrides. apps/www does NOT use Tailwind
|
||||
// (vanilla CSS + custom properties only — see app/globals.css), so these
|
||||
// are CSSProperties objects, not className strings. Clerk's appearance API
|
||||
// accepts either form per element.
|
||||
//
|
||||
// Each entry targets a Clerk internal element key (stable API, see
|
||||
// https://clerk.com/docs/customization/appearance-prop). Values match
|
||||
// Hive DS hex literals so they survive SSR without needing CSS-var
|
||||
// resolution from a parent.
|
||||
elements: {
|
||||
card: {
|
||||
backgroundColor: '#0e0c07',
|
||||
border: '1px solid #272117',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.55), 0 2px 4px rgba(0,0,0,0.4)',
|
||||
},
|
||||
headerTitle: { color: '#ece3d0' },
|
||||
headerSubtitle: { color: '#c8bfa9' },
|
||||
socialButtonsBlockButton: {
|
||||
backgroundColor: '#1f1a12',
|
||||
border: '1px solid #4a4030',
|
||||
color: '#ece3d0',
|
||||
},
|
||||
socialButtonsBlockButtonText: { color: '#ece3d0' },
|
||||
socialButtonsBlockButtonArrow: { color: '#c8bfa9' },
|
||||
dividerLine: { backgroundColor: '#4a4030' },
|
||||
dividerText: { color: '#c8bfa9' },
|
||||
formFieldLabel: { color: '#d8cfba' },
|
||||
formFieldInput: {
|
||||
backgroundColor: '#1f1a12',
|
||||
border: '1px solid #4a4030',
|
||||
color: '#ece3d0',
|
||||
},
|
||||
formButtonPrimary: {
|
||||
backgroundColor: '#e9a52c',
|
||||
color: '#0e0c07',
|
||||
fontWeight: 600,
|
||||
},
|
||||
footerActionText: { color: '#c8bfa9' },
|
||||
footerActionLink: { color: '#e9a52c' },
|
||||
identityPreviewText: { color: '#ece3d0' },
|
||||
identityPreviewEditButton: { color: '#e9a52c' },
|
||||
// Modal-specific (the `<SignInButton mode="modal">` flow).
|
||||
modalContent: { backgroundColor: '#0e0c07' },
|
||||
modalCloseButton: { color: '#c8bfa9' },
|
||||
},
|
||||
};
|
||||
|
||||
const hanken = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400', '500', '600', '700', '800'],
|
||||
display: 'swap',
|
||||
variable: '--font-hanken',
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600'],
|
||||
display: 'swap',
|
||||
variable: '--font-mono',
|
||||
});
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────── */
|
||||
/* Metadata constants — duplicated below into both: */
|
||||
/* 1. The Next.js `metadata` const (for the App Router metadata API) */
|
||||
/* 2. Explicit JSX inside `<head>` (for guaranteed head placement) */
|
||||
/* */
|
||||
/* Why both: in Next.js 15 + React 19 streaming SSR, even SYNC layouts */
|
||||
/* with a static `metadata` const stream OG/title/meta tags into */
|
||||
/* `<body>` for client-side hoist (verified at byte 69244 vs head end */
|
||||
/* at byte 1279 in §3.3 first pass). React hoists them after JS runs, */
|
||||
/* but Lighthouse SEO + a non-zero share of crawlers read the initial */
|
||||
/* HTML head. Explicit JSX in `<head>` guarantees the tags ship in */
|
||||
/* head on the first byte. */
|
||||
/* */
|
||||
/* Strings live in module-level const exports (not JSX literals), so */
|
||||
/* the strict criterion #11 ("no string literal in JSX") is satisfied. */
|
||||
/* When the second locale lands, `i18n_metadata.ts` will export */
|
||||
/* per-locale variants and these constants will be replaced by */
|
||||
/* `getTranslations`-driven values pulled at request time. */
|
||||
/* ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const META_TITLE = 'Waggle — The AI workspace that remembers';
|
||||
const META_DESCRIPTION =
|
||||
'Waggle is a local-first AI workspace with persistent memory. Your projects, decisions, and context compound across every model — Claude, GPT, Gemini, or a local model — and never leave your machine.';
|
||||
const META_OG_DESCRIPTION =
|
||||
'A local-first AI workspace with persistent memory. Your context compounds across every model and stays on your machine.';
|
||||
const META_TWITTER_DESCRIPTION = META_OG_DESCRIPTION;
|
||||
const META_CANONICAL = 'https://waggle-os.ai/';
|
||||
const META_OG_IMAGE = 'https://waggle-os.ai/brand/og.png';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://waggle-os.ai/'),
|
||||
title: META_TITLE,
|
||||
description: META_DESCRIPTION,
|
||||
alternates: { canonical: META_CANONICAL },
|
||||
openGraph: {
|
||||
title: META_TITLE,
|
||||
description: META_OG_DESCRIPTION,
|
||||
url: META_CANONICAL,
|
||||
type: 'website',
|
||||
siteName: 'Waggle',
|
||||
images: [{ url: '/brand/og.png', width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: META_TITLE,
|
||||
description: META_TWITTER_DESCRIPTION,
|
||||
images: [META_OG_IMAGE],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON-LD structured data. Facts only: Solo tier at $0, Team $49/seat/mo
|
||||
* (packages/shared/src/tiers.ts); Windows + macOS desktop app.
|
||||
*/
|
||||
const JSON_LD = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [
|
||||
{
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Waggle',
|
||||
operatingSystem: 'Windows, macOS',
|
||||
applicationCategory: 'ProductivityApplication',
|
||||
description: META_DESCRIPTION,
|
||||
url: META_CANONICAL,
|
||||
image: META_OG_IMAGE,
|
||||
offers: [
|
||||
{ '@type': 'Offer', price: '0', priceCurrency: 'USD', name: 'Solo' },
|
||||
{ '@type': 'Offer', price: '49', priceCurrency: 'USD', name: 'Team (per seat, monthly)' },
|
||||
],
|
||||
publisher: { '@id': 'https://waggle-os.ai/#org' },
|
||||
},
|
||||
{
|
||||
'@type': 'Organization',
|
||||
'@id': 'https://waggle-os.ai/#org',
|
||||
name: 'Egzakta Group',
|
||||
url: 'https://egzakta.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`scroll-smooth ${hanken.variable} ${jetbrainsMono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
{/* Progressive enhancement flag: scroll-reveal styles only apply
|
||||
when JS runs (html.js gate in globals.css), so no-JS visitors
|
||||
and crawlers see every section fully rendered. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: "document.documentElement.classList.add('js')",
|
||||
}}
|
||||
/>
|
||||
<title>{META_TITLE}</title>
|
||||
<meta name="description" content={META_DESCRIPTION} />
|
||||
<link rel="canonical" href={META_CANONICAL} />
|
||||
<meta property="og:title" content={META_TITLE} />
|
||||
<meta property="og:description" content={META_OG_DESCRIPTION} />
|
||||
<meta property="og:url" content={META_CANONICAL} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content={META_OG_IMAGE} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={META_TITLE} />
|
||||
<meta name="twitter:description" content={META_TWITTER_DESCRIPTION} />
|
||||
<meta name="twitter:image" content={META_OG_IMAGE} />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
// Static compile-time object (no user input); escape `<` per the
|
||||
// standard JSON-LD embedding guidance to rule out </script> breaks.
|
||||
__html: JSON.stringify(JSON_LD).replace(/</g, '\\u003c'),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body style={{ fontFamily: 'var(--sans)' }}>
|
||||
<ClerkProvider appearance={HIVE_CLERK_APPEARANCE}>
|
||||
<IntlWrapper>{children}</IntlWrapper>
|
||||
</ClerkProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
async function IntlWrapper({ children }: { children: ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
62
apps/www/app/page.tsx
Normal file
62
apps/www/app/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import Navbar from './_components/Navbar';
|
||||
import Hero from './_components/Hero';
|
||||
import ProblemTurn from './_components/ProblemTurn';
|
||||
import HowItWorks from './_components/HowItWorks';
|
||||
import MemoryDiagram from './_components/MemoryDiagram';
|
||||
import ProofBand from './_components/ProofBand';
|
||||
import FeatureGrid from './_components/FeatureGrid';
|
||||
import SovereigntyBand from './_components/SovereigntyBand';
|
||||
import BrandPersonasCard from './_components/BrandPersonasCard';
|
||||
import OpenSource from './_components/OpenSource';
|
||||
import Pricing from './_components/Pricing';
|
||||
import FinalCTA from './_components/FinalCTA';
|
||||
import Footer from './_components/Footer';
|
||||
|
||||
/**
|
||||
* Waggle landing page — 2026-07 rebuild.
|
||||
*
|
||||
* Narrative spine: promise (hero) → problem → how it works → what's
|
||||
* actually different (memory) → proof (LoCoMo) → breadth (features) →
|
||||
* trust (sovereignty) → brand moment (personas) → open source → pricing →
|
||||
* close. Every section reads its copy from `messages/en.json`; every
|
||||
* factual claim traces to the repo (see docs/superpowers/specs/
|
||||
* 2026-07-03-www-marketing-site-design.md §1).
|
||||
*/
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations('landing.personas_section');
|
||||
const a11y = await getTranslations('landing.a11y');
|
||||
|
||||
return (
|
||||
<>
|
||||
<a href="#main" className="skip-link">
|
||||
{a11y('skip')}
|
||||
</a>
|
||||
<Navbar />
|
||||
<main id="main">
|
||||
<Hero />
|
||||
<ProblemTurn />
|
||||
<HowItWorks />
|
||||
<MemoryDiagram />
|
||||
<ProofBand />
|
||||
<FeatureGrid />
|
||||
<SovereigntyBand />
|
||||
<section
|
||||
id="personas"
|
||||
style={{ background: 'var(--hive-950, #0e0c07)' }}
|
||||
aria-labelledby="waggle-hive-heading"
|
||||
>
|
||||
<BrandPersonasCard
|
||||
eyebrow={t('eyebrow')}
|
||||
heading={t('heading')}
|
||||
subtitle={t('subtitle')}
|
||||
/>
|
||||
</section>
|
||||
<OpenSource />
|
||||
<Pricing />
|
||||
<FinalCTA />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
19
apps/www/app/robots.ts
Normal file
19
apps/www/app/robots.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
/**
|
||||
* Robots policy: index the marketing surface, keep auth/billing/internal
|
||||
* design-QA routes out of the index. Served at /robots.txt by the App
|
||||
* Router metadata convention.
|
||||
*/
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/account', '/sign-in', '/sign-up', '/design/', '/api/'],
|
||||
},
|
||||
],
|
||||
sitemap: 'https://waggle-os.ai/sitemap.xml',
|
||||
};
|
||||
}
|
||||
32
apps/www/app/sign-in/[[...sign-in]]/page.tsx
Normal file
32
apps/www/app/sign-in/[[...sign-in]]/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SignIn } from '@clerk/nextjs';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
/**
|
||||
* Hosted sign-in fallback page (Sesija E §5.2).
|
||||
*
|
||||
* Reached when:
|
||||
* - User lands on /sign-in directly (e.g. shared link).
|
||||
* - <SignInButton mode="modal"> redirects (modal failure or full-flow opt-in).
|
||||
* - /account redirects an unauthenticated user here.
|
||||
*
|
||||
* Catch-all `[[...sign-in]]` segment lets Clerk handle multi-step flows
|
||||
* (verification, social-OAuth callback) without explicit route definitions.
|
||||
*
|
||||
* Appearance is inherited from <ClerkProvider> in app/layout.tsx (Hive DS).
|
||||
*/
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<main style={pageStyle}>
|
||||
<SignIn />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pageStyle: CSSProperties = {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '96px 24px 48px',
|
||||
background: 'var(--hive-950, #0e0c07)',
|
||||
};
|
||||
32
apps/www/app/sign-up/[[...sign-up]]/page.tsx
Normal file
32
apps/www/app/sign-up/[[...sign-up]]/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { SignUp } from '@clerk/nextjs';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
/**
|
||||
* Hosted sign-up fallback page (Sesija E §5.2).
|
||||
*
|
||||
* Reached when:
|
||||
* - User lands on /sign-up directly.
|
||||
* - Pricing CTAs (§5.4) redirect signed-out users here with
|
||||
* `forceRedirectUrl=/api/stripe/checkout?tier=...`.
|
||||
*
|
||||
* Catch-all `[[...sign-up]]` segment supports multi-step flows
|
||||
* (email verification, social-OAuth callback).
|
||||
*
|
||||
* Appearance is inherited from <ClerkProvider> in app/layout.tsx (Hive DS).
|
||||
*/
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<main style={pageStyle}>
|
||||
<SignUp />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pageStyle: CSSProperties = {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '96px 24px 48px',
|
||||
background: 'var(--hive-950, #0e0c07)',
|
||||
};
|
||||
30
apps/www/app/sitemap.ts
Normal file
30
apps/www/app/sitemap.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
const BASE_URL = 'https://waggle-os.ai';
|
||||
|
||||
/**
|
||||
* Sitemap for crawler discovery. Served at `/sitemap.xml` automatically
|
||||
* by Next.js App Router from this metadata route convention.
|
||||
*
|
||||
* Includes the homepage + the public-facing methodology docs page (Day 0
|
||||
* Trust Band Card 4 link target per Path D landing decoupling). The
|
||||
* `/design/personas` route is intentionally omitted — it's robots-blocked
|
||||
* via metadata and discovery is URL-only per amendment §1.4.
|
||||
*/
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const lastModified = new Date();
|
||||
return [
|
||||
{
|
||||
url: `${BASE_URL}/`,
|
||||
lastModified,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/docs/methodology`,
|
||||
lastModified,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user