diff --git a/website/README.md b/website/README.md
index a2243356..0a06e6c5 100644
--- a/website/README.md
+++ b/website/README.md
@@ -9,7 +9,14 @@ Next.js landing page (`website/`). The playable app lives in `frontend/`.
pnpm dev:web
```
-Copy `env.example` to `.env.local` and set `NEXT_PUBLIC_APP_URL` to your local or deployed app URL.
+Copy `env.example` to `.env.local` and set:
+
+- `NEXT_PUBLIC_APP_URL` — the local or deployed **app** URL, used by the "Play Now" CTA.
+- `NEXT_PUBLIC_SITE_URL` — the public origin of **this site**, used for canonical
+ URLs, `sitemap.xml`, `robots.txt` and Open Graph tags. It defaults to
+ `http://localhost:3002`, so a production deploy that leaves it unset publishes a
+ canonical tag and sitemap pointing at localhost. The two are different domains;
+ do not reuse `NEXT_PUBLIC_APP_URL` here.
## Deploy to Vercel (GitHub Actions)
@@ -43,6 +50,7 @@ CI runs `vercel pull` / `vercel deploy` from the monorepo root so `pnpm-lock.yam
| `VERCEL_ORG_ID` | Secret | Team/user ID |
| `VERCEL_WEBSITE_PROJECT_ID` | Secret | This website project’s ID |
| `NEXT_PUBLIC_APP_URL` | Variable | Public URL of the `frontend` app |
+| `NEXT_PUBLIC_SITE_URL` | Variable | Public URL of this marketing site (canonical, sitemap, OG) |
Workflow: none. `.github/workflows/website.yml` was removed in 49c5e63, and the
secrets table above plus the triggers below describe that workflow. They have
diff --git a/website/env.example b/website/env.example
index 6762e415..974cbbdb 100644
--- a/website/env.example
+++ b/website/env.example
@@ -1,2 +1,7 @@
-# URL of the deployed Crypto Pets app (frontend) — used by the landing "Start Playing" CTA
+# URL of the deployed Crypto Pets app (frontend) — used by the landing "Play Now" CTA
NEXT_PUBLIC_APP_URL=http://localhost:5173
+
+# Public origin of THIS marketing site — used for canonical URLs, sitemap.xml,
+# robots.txt and Open Graph tags. Must be set in production, or crawlers are
+# handed localhost. Not the same as NEXT_PUBLIC_APP_URL above.
+NEXT_PUBLIC_SITE_URL=http://localhost:3002
diff --git a/website/public/images/pets/cat.png b/website/public/images/pets/cat.png
deleted file mode 100644
index f352c0ba..00000000
Binary files a/website/public/images/pets/cat.png and /dev/null differ
diff --git a/website/public/images/pets/cinder-streak.png b/website/public/images/pets/cinder-streak.png
new file mode 100644
index 00000000..ec7872d9
Binary files /dev/null and b/website/public/images/pets/cinder-streak.png differ
diff --git a/website/public/images/pets/dinosaur.png b/website/public/images/pets/dinosaur.png
deleted file mode 100644
index b920ca39..00000000
Binary files a/website/public/images/pets/dinosaur.png and /dev/null differ
diff --git a/website/public/images/pets/dog.png b/website/public/images/pets/dog.png
deleted file mode 100644
index c6d1ce7c..00000000
Binary files a/website/public/images/pets/dog.png and /dev/null differ
diff --git a/website/public/images/pets/elephant.png b/website/public/images/pets/elephant.png
deleted file mode 100644
index bf9d5c72..00000000
Binary files a/website/public/images/pets/elephant.png and /dev/null differ
diff --git a/website/public/images/pets/ember-oracle.png b/website/public/images/pets/ember-oracle.png
new file mode 100644
index 00000000..384ba664
Binary files /dev/null and b/website/public/images/pets/ember-oracle.png differ
diff --git a/website/public/images/pets/lion.png b/website/public/images/pets/lion.png
deleted file mode 100644
index 4a8a057b..00000000
Binary files a/website/public/images/pets/lion.png and /dev/null differ
diff --git a/website/public/images/pets/nova-brute.png b/website/public/images/pets/nova-brute.png
new file mode 100644
index 00000000..27b619b8
Binary files /dev/null and b/website/public/images/pets/nova-brute.png differ
diff --git a/website/public/images/pets/owl.png b/website/public/images/pets/owl.png
deleted file mode 100644
index 14894515..00000000
Binary files a/website/public/images/pets/owl.png and /dev/null differ
diff --git a/website/public/images/pets/starlit-prowler.png b/website/public/images/pets/starlit-prowler.png
new file mode 100644
index 00000000..158984ac
Binary files /dev/null and b/website/public/images/pets/starlit-prowler.png differ
diff --git a/website/public/images/pets/tidefang.png b/website/public/images/pets/tidefang.png
new file mode 100644
index 00000000..426535f6
Binary files /dev/null and b/website/public/images/pets/tidefang.png differ
diff --git a/website/public/images/pets/umbral-bulwark.png b/website/public/images/pets/umbral-bulwark.png
new file mode 100644
index 00000000..b93c8743
Binary files /dev/null and b/website/public/images/pets/umbral-bulwark.png differ
diff --git a/website/public/llms.txt b/website/public/llms.txt
index f8117313..fe0a1816 100644
--- a/website/public/llms.txt
+++ b/website/public/llms.txt
@@ -1,25 +1,48 @@
# CryptoPets
-> A fully on-chain creature collection and battle game. Mint, breed, train, and
-> battle pet NFTs across Ethereum and Solana — playable on web and mobile.
+> A creature collection and battle game where the pets are NFTs. Mint, breed,
+> train and battle across Ethereum and Solana — on web and mobile.
This is the marketing landing site. The playable app lives at a separate URL
-(see Links). Pets are NFTs held in the player's own wallet — mintable, tradable,
-and transferable independent of the app.
+(see Links). Pets and items are NFTs held in the player's own wallet — mintable,
+tradable, and transferable independent of the app.
## Overview
-- **What it is:** on-chain pet collection + tactical battle game (NFTs).
-- **Chains:** Ethereum and Solana, with additional rollups planned through Q3.
-- **Wallets:** MetaMask, Phantom, or social login.
+- **What it is:** on-chain pet collection and tactical battle game (NFTs).
+- **Chains:** Ethereum and Solana. Pets, breeding and battles run on both;
+ equipment and item NFTs are on Ethereum today.
+- **Wallets:** MetaMask on Ethereum, Phantom on Solana.
- **Platforms:** web and mobile.
## Gameplay
-- **Connect** any wallet (MetaMask, Phantom, social login).
-- **Mint** a pet from the egg pool and roll for a rare trait.
-- **Train** — level up, breed new bloodlines, unlock signature abilities.
-- **Battle** rivals in the arena and climb the global leaderboard.
+- **Connect** the wallet you already have.
+- **Hatch** a starter pet whose traits come from on-chain randomness.
+- **Raise** it — level up, train stats, equip gear, breed bloodlines, marry a
+ partner pet (which unlocks a private thread between the two owners).
+- **Battle** rivals in the arena and climb the season leaderboard.
+
+## Provably fair battles
+
+- Defending requires a wallet-signed authorization naming the pet, the level band,
+ the exact rule set, a validity window and a daily cap. A pet cannot be dragged
+ into a fight its owner did not agree to.
+- Each battle is seeded from a public randomness beacon round committed **before**
+ the fight resolves, so no party can grind the roll.
+- Every result is a signed receipt over the exact inputs it used, and batches of
+ receipts are committed to a Merkle root written on chain.
+- An open-source verifier replays any receipt independently and reports seven
+ checks separately: seed derivation, operator signature, beacon signature,
+ combat replay, progression, equipment and hash-chain continuity. It needs no
+ account and no access to the project's servers.
+
+## Rewards
+
+- Seasons pay CPET to the top of the leaderboard, claimed against an on-chain
+ Merkle root with per-wallet and per-season ceilings.
+- CPET has a fixed supply and no mint function, so the total is checkable by
+ anyone and cannot be inflated later.
## Rarity tiers
@@ -30,24 +53,35 @@ and transferable independent of the app.
## Roadmap
-- Q1 — Genesis Drop (shipped): genesis mint, socials, first 10k supply.
-- Q2 — Arena (shipped): battle system v1, leaderboards, weekly tournaments.
-- Q3 — Bloodlines (in progress): breeding, cross-chain support, new rarity tier.
-- Q4 — Marketplace 2.0 (planned): native P2P trades, mobile app, guild system.
+Phases, not dates.
+
+- 01 Foundation (shipped): dual-chain minting, breeding and bloodlines, marriage,
+ level up, train, rename, transfer.
+- 02 Provable Arena (shipped): beacon-seeded battles, signed and chain-anchored
+ receipts, open-source public verifier.
+- 03 Progression (in progress): leaderboard and seasons, CPET season rewards,
+ equipment and item NFTs.
+- 04 Expansion (planned): daily quests, team battles, pet and item marketplace.
+
+## Built with
+
+Ethereum, Solana, Base, Pyth Entropy, Switchboard, drand, Metaplex, The Graph.
## Links
- [Play the app](https://cryptopets.vercel.app)
- [Source code (GitHub)](https://github.com/radcrew/do-not-stop)
-- [Features](/#features)
+- [Receipt verifier](https://github.com/radcrew/do-not-stop/tree/main/verifier)
- [How to play](/#how)
+- [Features](/#features)
- [Pets](/#pets)
+- [Provably fair](/#proof)
- [Roadmap](/#roadmap)
- [FAQ](/#faq)
-## Optional
+## Notes
-- Contracts are independently audited; audit reports are linked in the docs and
- updated on every major release.
- Connecting a wallet is free; mint price varies per drop and is announced ahead
- of time on community channels (Discord, Twitter).
+ of time on community channels.
+- Pets and items are tradable assets and seasons pay rewards to the top of the
+ leaderboard. This is a game you own a piece of, not an income stream.
diff --git a/website/src/app/layout.tsx b/website/src/app/layout.tsx
index dd9a2e25..c49026b1 100644
--- a/website/src/app/layout.tsx
+++ b/website/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Inter, Orbitron } from 'next/font/google';
+import { SITE_URL } from '@/lib/siteUrl';
import '../styles/globals.css';
const orbitron = Orbitron({
@@ -17,14 +18,67 @@ const inter = Inter({
display: 'swap',
});
+const TITLE = 'Crypto Pets';
+const DESCRIPTION =
+ 'Collect, breed and battle NFT pets on Ethereum and Solana. Every battle settles from a committed seed and ships with a receipt anyone can replay.';
+
export const metadata: Metadata = {
- title: 'Crypto Pets',
- description: 'Collect, battle and breed unique on-chain pets in the ultimate Crypto Pets adventure.',
+ // Required for Next to resolve the relative URLs below into absolute ones.
+ metadataBase: new URL(SITE_URL),
+ title: {
+ default: TITLE,
+ template: '%s · Crypto Pets',
+ },
+ description: DESCRIPTION,
+ applicationName: 'Crypto Pets',
+ keywords: [
+ 'NFT game',
+ 'on-chain game',
+ 'pet battler',
+ 'Ethereum',
+ 'Solana',
+ 'provably fair',
+ 'blockchain gaming',
+ ],
+ alternates: { canonical: '/' },
+ openGraph: {
+ type: 'website',
+ url: '/',
+ siteName: 'Crypto Pets',
+ title: TITLE,
+ description: DESCRIPTION,
+ locale: 'en_US',
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: TITLE,
+ description: DESCRIPTION,
+ },
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: { index: true, follow: true, 'max-image-preview': 'large' },
+ },
};
+/* Runs before first paint. Reveal targets start hidden only once this class is
+ present, so a blocked or failed bundle degrades to a fully visible page
+ instead of a blank one below the fold. */
+const REVEAL_GUARD = "document.documentElement.classList.add('js-reveal')";
+
export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
+ /* The guard above mutates this element's class list before React hydrates,
+ so the server markup and the client tree disagree on className by design.
+ Without this, that shows up as a hydration mismatch on every load. */
+
+
+
+
{children}
);
diff --git a/website/src/app/opengraph-image.tsx b/website/src/app/opengraph-image.tsx
new file mode 100644
index 00000000..a3da2ded
--- /dev/null
+++ b/website/src/app/opengraph-image.tsx
@@ -0,0 +1,105 @@
+import { ImageResponse } from 'next/og';
+
+export const alt = 'Crypto Pets - collect, battle and breed on-chain pets';
+export const size = { width: 1200, height: 630 };
+export const contentType = 'image/png';
+
+/**
+ * Generated at build time rather than checked in as a binary, so the card cannot
+ * drift out of sync with the copy it quotes.
+ *
+ * Satori (which renders this) supports only a subset of CSS: flexbox but not
+ * grid, and no `background-clip: text`. The neon gradient the page uses on its
+ * headline is therefore approximated here with solid accent colours and a
+ * gradient rule, rather than gradient-filled type.
+ */
+export default function OpengraphImage() {
+ return new ImageResponse(
+ (
+
+
+
+
+ On-Chain Pet Battler
+
+
+
+
+
+ Collect, Battle & Breed
+
+
+ Your Dream Pets
+
+
+
+
+
+
+ Every battle settles from a committed seed, with a receipt anyone can replay.
+
+
+ Ethereum · Solana
+
+
+
+ ),
+ size,
+ );
+}
diff --git a/website/src/app/page.tsx b/website/src/app/page.tsx
index 81b5f118..1fee5f13 100644
--- a/website/src/app/page.tsx
+++ b/website/src/app/page.tsx
@@ -9,9 +9,9 @@ import {
Hero,
HowItWorks,
Pets,
+ Proof,
Roadmap,
Stats,
- Testimonials,
} from '@/components/landing';
const LandingPage = () => (
@@ -24,7 +24,7 @@ const LandingPage = () => (
-
+
diff --git a/website/src/app/robots.ts b/website/src/app/robots.ts
new file mode 100644
index 00000000..ec46707f
--- /dev/null
+++ b/website/src/app/robots.ts
@@ -0,0 +1,11 @@
+import type { MetadataRoute } from 'next';
+
+import { SITE_URL } from '@/lib/siteUrl';
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: { userAgent: '*', allow: '/' },
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ host: SITE_URL,
+ };
+}
diff --git a/website/src/app/sitemap.ts b/website/src/app/sitemap.ts
new file mode 100644
index 00000000..ce3d7113
--- /dev/null
+++ b/website/src/app/sitemap.ts
@@ -0,0 +1,19 @@
+import type { MetadataRoute } from 'next';
+
+import { SITE_URL } from '@/lib/siteUrl';
+
+/**
+ * One entry: the landing page is a single document. Its sections are anchors on
+ * that document, not separate URLs, and listing them would advertise pages that
+ * do not exist.
+ */
+export default function sitemap(): MetadataRoute.Sitemap {
+ return [
+ {
+ url: SITE_URL,
+ lastModified: new Date(),
+ changeFrequency: 'weekly',
+ priority: 1,
+ },
+ ];
+}
diff --git a/website/src/app/twitter-image.tsx b/website/src/app/twitter-image.tsx
new file mode 100644
index 00000000..e05006b9
--- /dev/null
+++ b/website/src/app/twitter-image.tsx
@@ -0,0 +1,3 @@
+/* Twitter reuses the Open Graph card; Next does not derive twitter:image from
+ opengraph-image on its own. */
+export { default, size, contentType, alt } from './opengraph-image';
diff --git a/website/src/components/common/NeonButton/NeonButton.css b/website/src/components/common/NeonButton/NeonButton.css
index dc724ae9..46da64aa 100644
--- a/website/src/components/common/NeonButton/NeonButton.css
+++ b/website/src/components/common/NeonButton/NeonButton.css
@@ -1,16 +1,18 @@
-/* Base palette = azure (default tone; no .tone-azure rule needed) */
+/* Base palette = azure (default tone; no .tone-azure rule needed).
+ Per-tone alphas differ on purpose — the more saturated hues need less of
+ themselves to read at the same weight — so each tone restates all three. */
.neon-btn {
- --btn-text: #8ed1ff;
- --btn-border: rgb(30 157 255 / 50%);
- --btn-glow: rgb(30 157 255 / 24%);
- --btn-inner: rgb(30 157 255 / 14%);
- --btn-highlight: rgb(255 255 255 / 3%);
+ --btn-text: var(--tone-azure-text);
+ --btn-border: rgb(var(--tone-azure-rgb) / 50%);
+ --btn-glow: rgb(var(--tone-azure-rgb) / 24%);
+ --btn-inner: rgb(var(--tone-azure-rgb) / 14%);
+ --btn-highlight: var(--surface-veil-1);
position: relative;
isolation: isolate;
border: 1px solid var(--btn-border);
- border-radius: 2px;
- background: linear-gradient(180deg, rgb(5 13 30 / 92%), rgb(4 8 22 / 95%));
+ border-radius: var(--radius-xs);
+ background: var(--surface-control);
color: var(--btn-text);
font-weight: 700;
letter-spacing: 0.95px;
@@ -20,12 +22,19 @@
inset 0 0 12px var(--btn-inner),
inset 0 0 24px var(--btn-highlight),
0 0 12px var(--btn-glow);
- transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
+ transition:
+ border-color var(--dur-base) var(--ease-out),
+ box-shadow var(--dur-base) var(--ease-out),
+ transform var(--dur-base) var(--ease-out);
&::before {
content: '';
position: absolute;
inset: 0;
+ /* Follows whatever radius the button is given. Without this the sheen is a
+ hard rectangle, which is invisible at the default 2px but shows square
+ corners the moment a caller rounds the button. */
+ border-radius: inherit;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 6%), transparent);
}
@@ -78,24 +87,24 @@
}
&.tone-emerald {
- --btn-text: #9effd4;
- --btn-border: rgb(0 255 157 / 48%);
- --btn-glow: rgb(0 255 157 / 24%);
- --btn-inner: rgb(0 255 157 / 14%);
+ --btn-text: var(--tone-emerald-text);
+ --btn-border: rgb(var(--tone-emerald-rgb) / 48%);
+ --btn-glow: rgb(var(--tone-emerald-rgb) / 24%);
+ --btn-inner: rgb(var(--tone-emerald-rgb) / 14%);
}
&.tone-amber {
- --btn-text: #ffd07b;
- --btn-border: rgb(255 181 67 / 44%);
- --btn-glow: rgb(255 181 67 / 22%);
- --btn-inner: rgb(255 181 67 / 12%);
+ --btn-text: var(--tone-amber-text);
+ --btn-border: rgb(var(--tone-amber-rgb) / 44%);
+ --btn-glow: rgb(var(--tone-amber-rgb) / 22%);
+ --btn-inner: rgb(var(--tone-amber-rgb) / 12%);
}
&.tone-cyan {
- --btn-text: #86ebff;
- --btn-border: rgb(0 247 255 / 36%);
- --btn-glow: rgb(0 247 255 / 18%);
- --btn-inner: rgb(0 247 255 / 10%);
+ --btn-text: var(--tone-cyan-text);
+ --btn-border: rgb(var(--tone-cyan-rgb) / 36%);
+ --btn-glow: rgb(var(--tone-cyan-rgb) / 18%);
+ --btn-inner: rgb(var(--tone-cyan-rgb) / 10%);
}
@media (prefers-reduced-motion: reduce) {
diff --git a/website/src/components/common/NeonCard/NeonCard.css b/website/src/components/common/NeonCard/NeonCard.css
index dedc27fa..fc44423c 100644
--- a/website/src/components/common/NeonCard/NeonCard.css
+++ b/website/src/components/common/NeonCard/NeonCard.css
@@ -1,12 +1,12 @@
.neon-card {
position: relative;
- border-radius: var(--border-radius, 12px);
+ border-radius: var(--radius-lg);
overflow: hidden;
transition:
- transform 180ms ease,
- box-shadow 180ms ease,
- border-color 180ms ease,
- filter 180ms ease;
+ transform var(--dur-base) var(--ease-out),
+ box-shadow var(--dur-base) var(--ease-out),
+ border-color var(--dur-base) var(--ease-out),
+ filter var(--dur-base) var(--ease-out);
&::after {
content: '';
@@ -16,16 +16,18 @@
pointer-events: none;
opacity: 0;
box-shadow:
- inset 0 0 0 1px rgb(126 170 255 / 24%),
- inset 0 0 22px rgb(86 136 255 / 12%);
- transition: opacity 180ms ease, box-shadow 180ms ease;
+ inset 0 0 0 1px rgb(var(--border-rgb) / 24%),
+ inset 0 0 22px rgb(var(--border-rgb) / 12%);
+ transition:
+ opacity var(--dur-base) var(--ease-out),
+ box-shadow var(--dur-base) var(--ease-out);
}
&:hover::after {
opacity: 1;
box-shadow:
- inset 0 0 0 1px rgb(148 191 255 / 42%),
- inset 0 0 30px rgb(100 152 255 / 24%);
+ inset 0 0 0 1px rgb(var(--border-rgb) / 42%),
+ inset 0 0 30px rgb(var(--border-rgb) / 24%);
}
&:hover {
@@ -41,8 +43,8 @@
&:active::after {
opacity: 1;
box-shadow:
- inset 0 0 0 1px rgb(120 170 255 / 36%),
- inset 0 0 18px rgb(84 136 255 / 16%);
+ inset 0 0 0 1px rgb(var(--border-rgb) / 36%),
+ inset 0 0 18px rgb(var(--border-rgb) / 16%);
}
@media (prefers-reduced-motion: reduce) {
diff --git a/website/src/components/common/SpotlightGroup/SpotlightGroup.tsx b/website/src/components/common/SpotlightGroup/SpotlightGroup.tsx
new file mode 100644
index 00000000..0b4f98ac
--- /dev/null
+++ b/website/src/components/common/SpotlightGroup/SpotlightGroup.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { useEffect, useRef, type HTMLAttributes } from 'react';
+
+import { hasFinePointer } from '@/lib/media';
+import { rafThrottle } from '@/lib/rafThrottle';
+
+/**
+ * Publishes the pointer's position over the hovered `[data-spotlight]` descendant
+ * as `--mx` / `--my`, in px relative to that element's own box.
+ *
+ * A single delegated listener on the group rather than one per card, and a thin
+ * client boundary so the section rendering the cards stays a server component.
+ *
+ * The layout read happens once per animation frame inside the rAF callback, not
+ * in the move handler — reading `getBoundingClientRect` per pointermove forces
+ * layout on every event, and pointermove fires far faster than the screen
+ * refreshes.
+ */
+export default function SpotlightGroup({
+ children,
+ ...props
+}: HTMLAttributes) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const root = ref.current;
+ if (!root) return;
+
+ // A spotlight is a hover affordance; a coarse pointer has nothing to track.
+ if (!hasFinePointer()) return;
+
+ let target: HTMLElement | null = null;
+ let clientX = 0;
+ let clientY = 0;
+
+ const clear = (el: HTMLElement | null) => {
+ el?.style.removeProperty('--mx');
+ el?.style.removeProperty('--my');
+ };
+
+ const apply = rafThrottle(() => {
+ if (!target) return;
+ const rect = target.getBoundingClientRect();
+ target.style.setProperty('--mx', `${clientX - rect.left}px`);
+ target.style.setProperty('--my', `${clientY - rect.top}px`);
+ });
+
+ const onMove = (event: PointerEvent) => {
+ const next = (event.target as Element | null)?.closest('[data-spotlight]') ?? null;
+
+ if (next !== target) {
+ clear(target);
+ target = next;
+ }
+ if (!target) return;
+
+ clientX = event.clientX;
+ clientY = event.clientY;
+ apply();
+ };
+
+ const onLeave = () => {
+ clear(target);
+ target = null;
+ };
+
+ root.addEventListener('pointermove', onMove, { passive: true });
+ root.addEventListener('pointerleave', onLeave, { passive: true });
+
+ return () => {
+ root.removeEventListener('pointermove', onMove);
+ root.removeEventListener('pointerleave', onLeave);
+ apply.cancel();
+ };
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/website/src/components/common/SpotlightGroup/index.ts b/website/src/components/common/SpotlightGroup/index.ts
new file mode 100644
index 00000000..3c5229aa
--- /dev/null
+++ b/website/src/components/common/SpotlightGroup/index.ts
@@ -0,0 +1 @@
+export { default } from './SpotlightGroup';
diff --git a/website/src/components/common/index.ts b/website/src/components/common/index.ts
index ef65cd96..bc1330a4 100644
--- a/website/src/components/common/index.ts
+++ b/website/src/components/common/index.ts
@@ -1,2 +1,3 @@
export { default as NeonButton } from './NeonButton';
export { default as NeonCard } from './NeonCard';
+export { default as SpotlightGroup } from './SpotlightGroup';
diff --git a/website/src/components/landing/Backers/Backers.css b/website/src/components/landing/Backers/Backers.css
index ee1b7439..81667b07 100644
--- a/website/src/components/landing/Backers/Backers.css
+++ b/website/src/components/landing/Backers/Backers.css
@@ -1,8 +1,7 @@
.backers {
padding: var(--section-pad-block-band) 0;
- background: rgb(5 8 18 / 86%);
- border-top: 1px solid rgb(96 123 255 / 18%);
- border-bottom: 1px solid rgb(96 123 255 / 18%);
+ background: rgb(var(--surface-void-rgb) / 86%);
+ border-block: 1px solid rgb(var(--border-rgb) / 18%);
text-align: center;
overflow: hidden;
@@ -12,8 +11,8 @@
font-weight: 700;
letter-spacing: 2.4px;
text-transform: uppercase;
- color: rgb(180 200 255 / 55%);
- margin: 0 0 14px;
+ color: var(--text-dim);
+ margin: 0 0 18px;
padding-inline: var(--landing-gutter);
}
@@ -21,45 +20,55 @@
position: relative;
width: 100%;
overflow: hidden;
- mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
+ mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
- &:hover .row { animation-play-state: paused; }
+ &:hover .row {
+ animation-play-state: paused;
+ }
}
+ /* Two identical lists side by side; translating the pair by exactly half its
+ width lands the second copy where the first began, so the loop has no seam. */
& .row {
- list-style: none;
- margin: 0;
- padding: 0;
- display: inline-flex;
- align-items: center;
- gap: 56px;
- animation: backers-scroll 28s linear infinite;
- white-space: nowrap;
+ display: flex;
width: max-content;
+ animation: backers-scroll 32s linear infinite;
+
+ & ul {
+ display: flex;
+ align-items: center;
+ gap: 56px;
+ list-style: none;
+ margin: 0;
+ padding-inline: 28px;
+ }
& li {
font-family: var(--title-font);
font-size: 1.05rem;
letter-spacing: 1.2px;
text-transform: uppercase;
+ white-space: nowrap;
color: rgb(220 230 255 / 80%);
- text-shadow: 0 0 10px rgb(118 154 255 / 35%), 0 0 22px rgb(118 154 255 / 18%);
- transition: color 180ms ease, text-shadow 180ms ease;
+ text-shadow:
+ 0 0 10px rgb(var(--border-rgb) / 35%),
+ 0 0 22px rgb(var(--border-rgb) / 18%);
+ transition:
+ color var(--dur-base) var(--ease-out),
+ text-shadow var(--dur-base) var(--ease-out);
&:hover {
- color: #ffffff;
- text-shadow: 0 0 12px rgb(168 198 255 / 70%), 0 0 28px rgb(118 154 255 / 45%);
+ color: var(--text-on-accent);
+ text-shadow:
+ 0 0 12px rgb(var(--neon-cyan-rgb) / 70%),
+ 0 0 28px rgb(var(--border-rgb) / 45%);
}
}
}
}
@keyframes backers-scroll {
- from { transform: translateX(0); }
- to { transform: translateX(-50%); }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .backers .row { animation: none; }
+ from { translate: 0; }
+ to { translate: -50%; }
}
diff --git a/website/src/components/landing/Backers/index.tsx b/website/src/components/landing/Backers/index.tsx
index 519c7934..0fc15172 100644
--- a/website/src/components/landing/Backers/index.tsx
+++ b/website/src/components/landing/Backers/index.tsx
@@ -1,21 +1,26 @@
-import { BACKERS } from '@/content/landing';
+import { BACKERS, BACKERS_LABEL } from '@/content/landing';
import './Backers.css';
-const Backers = () => {
- const loop = [...BACKERS, ...BACKERS];
-
- return (
-
- Powered by & built with
-
-
- {loop.map(({ name }, idx) => (
- - {name}
+const Backers = () => (
+
+ {BACKERS_LABEL}
+
+
+
+ {BACKERS.map(({ name }) => (
+ - {name}
+ ))}
+
+ {/* The loop needs a second copy to scroll seamlessly, but a screen reader
+ should not hear the list twice. */}
+
+ {BACKERS.map(({ name }) => (
+ - {name}
))}
-
- );
-};
+
+
+);
export default Backers;
diff --git a/website/src/components/landing/Community/Community.css b/website/src/components/landing/Community/Community.css
index bd1c28e9..40eee886 100644
--- a/website/src/components/landing/Community/Community.css
+++ b/website/src/components/landing/Community/Community.css
@@ -1,25 +1,53 @@
.community {
& .block {
+ position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
gap: 48px;
align-items: center;
- padding: 36px;
- border-radius: 18px;
+ padding: 40px;
+ border-radius: var(--radius-xl);
background:
- radial-gradient(circle at 8% 10%, rgb(155 100 255 / 22%), transparent 38%),
- radial-gradient(circle at 92% 90%, rgb(80 184 255 / 18%), transparent 40%),
- linear-gradient(180deg, rgb(10 14 28 / 92%), rgb(7 11 24 / 96%));
- border: 1px solid rgb(118 154 255 / 24%);
- box-shadow: 0 0 28px rgb(118 154 255 / 12%), inset 0 0 32px rgb(55 68 148 / 10%);
+ radial-gradient(circle at 8% 10%, rgb(var(--wash-violet-rgb) / 22%), transparent 38%),
+ radial-gradient(circle at 92% 90%, rgb(var(--rarity-rare-rgb) / 18%), transparent 40%),
+ linear-gradient(180deg, var(--surface-card-top), var(--surface-card-bottom));
+ border: 1px solid rgb(var(--border-rgb) / 24%);
+ box-shadow:
+ inset 0 1px 0 rgb(255 255 255 / 7%),
+ var(--glow-inset-lg),
+ var(--elev-3);
+
+ /* Static gradient edge. The one orbiting border on the page belongs to the
+ CTA panel; repeating it here would flatten that emphasis. */
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ padding: 1px;
+ pointer-events: none;
+ background: linear-gradient(
+ 135deg,
+ rgb(var(--neon-violet-rgb) / 55%) 0%,
+ transparent 40%,
+ transparent 62%,
+ rgb(var(--neon-cyan-rgb) / 45%) 100%
+ );
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
+ -webkit-mask-composite: xor;
+ mask-composite: exclude;
+ }
}
& .copy {
+ position: relative;
+
& .section-title { margin-bottom: 14px; }
& .section-subtitle { margin: 22px 0 0; }
}
& .chips {
+ position: relative;
list-style: none;
margin: 0;
padding: 0;
@@ -29,52 +57,77 @@
}
& .chip {
+ --brand-rgb: var(--border-rgb);
+
+ position: relative;
display: flex;
align-items: center;
gap: 12px;
- padding: 14px 16px;
- border-radius: 10px;
- background: rgb(255 255 255 / 3%);
- border: 1px solid rgb(118 154 255 / 22%);
+ padding: 15px 16px;
+ border-radius: var(--radius-md);
+ background: var(--surface-veil-1);
+ border: 1px solid rgb(var(--brand-rgb) / 24%);
text-decoration: none;
- color: #eaf0ff;
- transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease, background 180ms ease;
+ color: var(--text-bright);
+ overflow: hidden;
+ transition:
+ translate var(--dur-base) var(--ease-out),
+ border-color var(--dur-base) var(--ease-out),
+ box-shadow var(--dur-base) var(--ease-out),
+ background var(--dur-base) var(--ease-out);
- &:hover {
- transform: translateY(-2px);
- background: rgb(255 255 255 / 5%);
+ /* Brand wash that rises from the bottom edge on hover. */
+ &::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background: linear-gradient(0deg, rgb(var(--brand-rgb) / 20%), transparent 62%);
+ opacity: 0;
+ transition: opacity var(--dur-slow) var(--ease-out);
}
- &.discord {
- &:hover { border-color: rgb(88 101 242 / 55%); box-shadow: 0 0 18px rgb(88 101 242 / 35%); }
- & .chip-icon { color: #5865f2; filter: drop-shadow(0 0 8px rgb(88 101 242 / 55%)); }
- }
- &.twitter {
- &:hover { border-color: rgb(255 255 255 / 45%); box-shadow: 0 0 18px rgb(255 255 255 / 18%); }
- & .chip-icon { color: #ffffff; filter: drop-shadow(0 0 8px rgb(255 255 255 / 35%)); }
- }
- &.telegram {
- &:hover { border-color: rgb(41 182 246 / 55%); box-shadow: 0 0 18px rgb(41 182 246 / 38%); }
- & .chip-icon { color: #29b6f6; filter: drop-shadow(0 0 8px rgb(41 182 246 / 55%)); }
+ &:hover {
+ translate: 0 -3px;
+ background: var(--surface-veil-2);
+ border-color: rgb(var(--brand-rgb) / 62%);
+ box-shadow: 0 0 20px rgb(var(--brand-rgb) / 34%), var(--elev-2);
+
+ &::before { opacity: 1; }
}
- &.youtube {
- &:hover { border-color: rgb(255 0 51 / 55%); box-shadow: 0 0 18px rgb(255 0 51 / 38%); }
- & .chip-icon { color: #ff0033; filter: drop-shadow(0 0 8px rgb(255 0 51 / 55%)); }
+
+ &:focus-visible {
+ outline: 2px solid rgb(var(--brand-rgb) / 65%);
+ outline-offset: 2px;
}
+
+ &.discord { --brand-rgb: 88 101 242; }
+ &.twitter { --brand-rgb: 255 255 255; }
+ &.telegram { --brand-rgb: 41 182 246; }
+ &.youtube { --brand-rgb: 255 0 51; }
}
& .chip-icon {
+ position: relative;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
- width: 36px;
- height: 36px;
+ width: 42px;
+ height: 42px;
+ border-radius: var(--radius-md);
+ background: rgb(var(--brand-rgb) / 12%);
+ color: rgb(var(--brand-rgb));
+ filter: drop-shadow(0 0 8px rgb(var(--brand-rgb) / 55%));
- & svg { width: 100%; height: 100%; }
+ & svg {
+ width: 62%;
+ height: 62%;
+ }
}
& .chip-text {
+ position: relative;
display: flex;
flex-direction: column;
gap: 2px;
@@ -85,13 +138,13 @@
font-size: 0.92rem;
letter-spacing: 0.6px;
text-transform: uppercase;
- color: #f6f3ff;
+ color: var(--text-title);
}
& span {
font-family: var(--content-font);
font-size: 0.82rem;
- color: rgb(195 210 255 / 70%);
+ color: var(--text-muted);
}
}
}
@@ -105,5 +158,7 @@
}
@media (max-width: 480px) {
- .community .chips { grid-template-columns: 1fr; }
+ .community .chips {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/website/src/components/landing/Community/index.tsx b/website/src/components/landing/Community/index.tsx
index a7187509..80aeca55 100644
--- a/website/src/components/landing/Community/index.tsx
+++ b/website/src/components/landing/Community/index.tsx
@@ -1,24 +1,26 @@
-import { COMMUNITIES } from '@/content/landing';
+import { COMMUNITIES, SECTION_COPY } from '@/content/landing';
import Icon from './Icon';
import './Community.css';
+const { title, subtitle } = SECTION_COPY.community;
+
const Community = () => (
-