diff --git a/.agents/plans/pr-reconciliation-site-check.plan.md b/.agents/plans/pr-reconciliation-site-check.plan.md
new file mode 100644
index 00000000..6e344474
--- /dev/null
+++ b/.agents/plans/pr-reconciliation-site-check.plan.md
@@ -0,0 +1,67 @@
+---
+name: pr-reconciliation-site-check
+overview: "Reconcile the approved Stripe and typography PRs with main, verify their combined behavior, and investigate the reported site failure."
+todos:
+ - id: verify-live-failure
+ content: Reproduce the reported site failure and inspect production deployment and browser evidence
+ status: in_progress
+ - id: reconcile-stripe
+ content: Preserve null-body and malformed-ID validation tests while merging current main into PR 161
+ status: completed
+ - id: reconcile-typography
+ content: Resolve PR 171 font configuration conflicts and preserve the merged mobile theme control
+ status: completed
+ - id: verify-and-ship
+ content: Run local gates, review current PR checks and comments, squash-merge approved changes, and verify production
+ status: in_progress
+isProject: false
+---
+
+# PR reconciliation and site verification
+
+## Goal
+
+Finish the approved PR merges and restore any reproduced site failure, with production verification.
+
+## Scope
+
+- Reconcile PRs 161 and 171; review the newly opened PR 172 under the existing open-PR request.
+- Inspect public pages, browser errors, deployment state, and relevant runtime logs.
+- Preserve authentication, payment, chain, and data semantics.
+
+## Files
+
+- `web/__tests__/api/stripe-routes.test.ts`: retain the null-body and malformed-UUID regressions.
+- `web/app/globals.css` and `web/tailwind.config.ts`: retain Crimson Pro titles, Inter body/UI, and Inconsolata code.
+- `web/components/AppNavbar.tsx`: verify the merged mobile theme control remains available.
+- `web/app/skills/MarketplaceClient.tsx`: retain the loading button until the final requested batch finishes.
+- `web/app/globals.css` and `web/__tests__/app/typography-utilities.test.ts`: register semantic fonts in the active Tailwind v4 theme and compile-test font variants.
+- Any site-failure fix must follow a reproduced cause; record exact files here before editing.
+
+## Verification
+
+Run Node 24, focused Stripe tests, `npm run format:check`, web lint, typecheck, full web Vitest, `next build --webpack`, and `git diff --check`. Verify current PR head checks before squash merging and inspect the resulting production deployment and public browser behavior.
+
+## Rollout and rollback
+
+Update PR branches with signed merge commits, preserving history. Squash-merge after verification. If a reproduced release regression requires rollback, use a verified prior deployment or a focused revert; do not change live data or feature flags.
+
+## Evidence (2026-09-05)
+
+- Production alias points to deployment `dpl_E9bynP9hLSKYRTMoSQan4pHWNWd4`, commit `ccb432b`, state READY.
+- Marketplace loaded 94 skills, pagination reached page 2, and browser error logs were empty.
+- Ports 3000, 3001, and 3002 have no local listener; the user has been asked which page/action fails while independent work continues.
+- PR 161 conflicts only in its Stripe test insertion; PR 171 conflicts in global CSS and Tailwind font mapping.
+- PR 161 resolution passed format, lint, typecheck, all 948 tests, and the webpack production build. The build required network access for Google Fonts and used expected missing-database fallbacks in the isolated checkout. Signed commit `ea7a47c` was published.
+- Local marketplace at port 3002 loaded 9 then 18 cards. Searching for `subagent` after appending correctly replaced the list with one result; the suspected search regression was ruled out.
+- UI review found the end-of-list label can replace the button while the final page is still in flight, because the requested page advances before its response. Keep the button mounted and disabled until completion; no pagination/API semantics change.
+- The final-batch browser check showed a disabled `Loading more` button at 9/12, followed by 12/12 and the end label, with no console errors.
+- Skill detail computed styles showed the page title in Crimson Pro, prose in Inter, and code in Inconsolata, but Markdown headings incorrectly inherited Inter. `prose-headings:font-display` has no generated utility because the legacy TypeScript Tailwind config is not loaded by the v4 CSS entrypoint. Register the font tokens with `@theme inline` and verify generated CSS plus rendered styles.
+- All four compiled-utility regression tests failed before the theme mapping and passed afterward. The browser confirmed the corrected skill Markdown heading in Crimson Pro, body in Inter, and code in Inconsolata in dark theme. Light-theme marketplace and narrow-layout carousel navigation (01/03 to 02/03) also passed without horizontal overflow.
+- PRs 172 and 161 were squash-merged after green CI and Vercel builds; both are integrated into this branch from `origin/main` at `00972b7`.
+- Final combined local gate: format, lint, typecheck, 129 test files / 953 tests, and webpack production build all passed. Build retains the existing non-blocking `ox` dynamic-dependency warning. Real wallet signing and paid checkout were not exercised.
+- Production homepage, marketplace, dashboard, and sign-in page loaded successfully. Production runtime error checks returned no errors; no specific failing public URL/action has been supplied yet.
+
+## Blockers
+
+- The reported site failure is not yet reproduced; continue public-route checks and await the user's failing URL/action.
diff --git a/package-lock.json b/package-lock.json
index 761d9747..df244ca8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18140,6 +18140,34 @@
"integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==",
"license": "ISC"
},
+ "node_modules/embla-carousel": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
+ "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
+ "license": "MIT"
+ },
+ "node_modules/embla-carousel-react": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
+ "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
+ "license": "MIT",
+ "dependencies": {
+ "embla-carousel": "8.6.0",
+ "embla-carousel-reactive-utils": "8.6.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/embla-carousel-reactive-utils": {
+ "version": "8.6.0",
+ "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
+ "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "embla-carousel": "8.6.0"
+ }
+ },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -29470,6 +29498,7 @@
"8004-solana": "^0.8.3",
"ai": "^6.0.191",
"codama": "^1.5.1",
+ "embla-carousel-react": "^8.6.0",
"highlight.js": "^11.11.1",
"next": "16.1.6",
"next-themes": "^0.4.6",
diff --git a/web/__tests__/app/typography-utilities.test.ts b/web/__tests__/app/typography-utilities.test.ts
new file mode 100644
index 00000000..37bc0fa5
--- /dev/null
+++ b/web/__tests__/app/typography-utilities.test.ts
@@ -0,0 +1,32 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { compile } from "@tailwindcss/node";
+import { beforeAll, describe, expect, it } from "vitest";
+
+describe("semantic typography utilities", () => {
+ const cases = [
+ ["hover:font-title", "--font-crimson-pro"],
+ ["prose-headings:font-display", "--font-crimson-pro"],
+ ["hover:font-sans", "--font-inter"],
+ ["hover:font-mono", "--font-inconsolata"],
+ ] as const;
+ let css: string;
+
+ beforeAll(async () => {
+ const stylesheet = path.join(process.cwd(), "app/globals.css");
+ const compiler = await compile(await fs.readFile(stylesheet, "utf8"), {
+ base: path.dirname(stylesheet),
+ onDependency: () => {},
+ });
+ css = compiler.build(cases.map(([utility]) => utility));
+ });
+
+ it.each(cases)("generates %s using %s", (utility, fontVariable) => {
+ // Compile real variants: plain CSS classes alone cannot provide these.
+ const selector = `.${utility.replace(":", "\\:")}`;
+ const start = css.indexOf(`${selector} {`);
+ expect(start).toBeGreaterThanOrEqual(0);
+ const declaration = css.slice(start).match(/font-family:\s*([^;]+);/);
+ expect(declaration?.[1]).toContain(`var(${fontVariable})`);
+ });
+});
diff --git a/web/app/agent-reputation-system/page.tsx b/web/app/agent-reputation-system/page.tsx
index 5e9fd376..21779235 100644
--- a/web/app/agent-reputation-system/page.tsx
+++ b/web/app/agent-reputation-system/page.tsx
@@ -105,7 +105,7 @@ export default function AgentReputationSystemPage() {
AgentVouch is an on-chain agent reputation system for AI agents.
-
+
Why AI agents need a reputation system
@@ -121,7 +121,7 @@ export default function AgentReputationSystemPage() {
for the concrete failure mode.
-
+
How an agent reputation system works
@@ -147,7 +147,7 @@ export default function AgentReputationSystemPage() {
-
+
How AgentVouch implements it
@@ -169,7 +169,7 @@ export default function AgentReputationSystemPage() {
.
-
+
Agent reputation system vs. reputation oracle
@@ -187,12 +187,12 @@ export default function AgentReputationSystemPage() {
-
+
Frequently asked questions
-
+
What is an agent reputation system?
@@ -203,7 +203,7 @@ export default function AgentReputationSystemPage() {
-
+
How does AgentVouch score agent reputation?
diff --git a/web/app/author/[pubkey]/page.tsx b/web/app/author/[pubkey]/page.tsx
index 56e1d9cf..170ba370 100644
--- a/web/app/author/[pubkey]/page.tsx
+++ b/web/app/author/[pubkey]/page.tsx
@@ -1163,7 +1163,7 @@ export default function AuthorProfilePage() {
Report this author
@@ -1386,7 +1386,7 @@ export default function AuthorProfilePage() {
{authorDisputes.length > 0 && (
-
+
{" "}
Author Disputes
@@ -1448,7 +1448,7 @@ export default function AuthorProfilePage() {
{isOwnProfile && profile && (
-
+
Link Solana Agent Registry
@@ -1656,7 +1656,7 @@ export default function AuthorProfilePage() {
{stat.icon}
-
+
{stat.value}
@@ -1670,7 +1670,7 @@ export default function AuthorProfilePage() {
-
+
Manage Base
Self Stake
@@ -1758,7 +1758,7 @@ export default function AuthorProfilePage() {
-
+
Your Backing
@@ -1802,7 +1802,7 @@ export default function AuthorProfilePage() {
{/* Vouch for this author */}
{!isOwnProfile && (
-
+
Vouch for this
Author
@@ -1916,7 +1916,7 @@ export default function AuthorProfilePage() {
{!isOwnProfile && connected && viewerVouch && (
-
+
Voucher
Revenue
@@ -2024,7 +2024,7 @@ export default function AuthorProfilePage() {
{/* Skills */}
-
+
Published
Skills
@@ -2045,7 +2045,7 @@ export default function AuthorProfilePage() {
className="rounded-sm border border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 p-4 hover:border-gray-300 dark:hover:border-gray-600 transition block"
>
-
+
{skill.name}
{skill.price_usdc_micros ? (
@@ -2097,7 +2097,7 @@ export default function AuthorProfilePage() {
className="rounded-sm border border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 p-4 hover:border-gray-300 dark:hover:border-gray-600 transition block"
>
-
+
{skill.name || "Untitled"}
{priceUsdcMicros ? (
@@ -2134,7 +2134,7 @@ export default function AuthorProfilePage() {
{/* Vouchers (who vouches for this author) */}
{vouchesReceived.length > 0 && (
-
+
Vouchers
@@ -2209,7 +2209,7 @@ export default function AuthorProfilePage() {
{/* Vouching for (who this author vouches for) */}
{vouchesGiven.length > 0 && (
-
+
Vouching For
@@ -2268,7 +2268,7 @@ export default function AuthorProfilePage() {
-
+
Report
this author
diff --git a/web/app/blog/[slug]/page.tsx b/web/app/blog/[slug]/page.tsx
index fc2bf5e6..6e4f0b01 100644
--- a/web/app/blog/[slug]/page.tsx
+++ b/web/app/blog/[slug]/page.tsx
@@ -73,7 +73,7 @@ export default async function BlogPostPage({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(blogPostingJsonLd) }}
/>
-
+
No posts yet.
) : (
-
+
{posts.map((post) => (
diff --git a/web/app/dashboard/page.tsx b/web/app/dashboard/page.tsx
index 8f24599e..bcb24430 100644
--- a/web/app/dashboard/page.tsx
+++ b/web/app/dashboard/page.tsx
@@ -787,7 +787,7 @@ export default function DashboardPage() {
const renderRegisteredAgentsCard = () => (
-
+
Registered Agents
-
+
Your Agent Profile
{publicKey && (
@@ -1142,7 +1142,7 @@ export default function DashboardPage() {
-
+
{" "}
Purchased Skills
@@ -1222,7 +1222,7 @@ export default function DashboardPage() {
-
+
Your
Marketplace Listings
@@ -1324,7 +1324,7 @@ export default function DashboardPage() {
{agentProfile && (
-
+
{" "}
Voucher Revenue
@@ -1509,7 +1509,7 @@ export default function DashboardPage() {
{agentProfile && vouchesReceived.length > 0 && (
-
+
Agents
Vouching For You
@@ -1572,7 +1572,7 @@ export default function DashboardPage() {
{agentProfile && vouches.length > 0 && (
-
+
Agents
You're Vouching For
@@ -1656,7 +1656,7 @@ export default function DashboardPage() {
{activeTab === "vouch" && connected && agentProfile && (
-
+
Vouch for an Agent
@@ -1708,7 +1708,7 @@ export default function DashboardPage() {
{activeTab === "vouch" && connected && !agentProfile && (
-
+
Vouch for an Agent
@@ -1723,7 +1723,7 @@ export default function DashboardPage() {
{activeTab === "explorer" && (
-
+
Search
Agents
@@ -1751,7 +1751,7 @@ export default function DashboardPage() {
{searchedAgent && (
-
+
Agent Found
@@ -1860,7 +1860,7 @@ export default function DashboardPage() {
-
+
All Registered Agents
-
+
Author
Disputes
@@ -2073,7 +2073,7 @@ export default function DashboardPage() {
-
+
Your
Vouches
diff --git a/web/app/docs/agent-reputation-system-vs-oracle/page.tsx b/web/app/docs/agent-reputation-system-vs-oracle/page.tsx
index a02c6462..c442ff61 100644
--- a/web/app/docs/agent-reputation-system-vs-oracle/page.tsx
+++ b/web/app/docs/agent-reputation-system-vs-oracle/page.tsx
@@ -63,7 +63,7 @@ export default function VsOraclePage() {
is the interface an agent queries to read that record.
-
+
The distinction
@@ -79,7 +79,7 @@ export default function VsOraclePage() {
-
+
AgentVouch is both
diff --git a/web/app/docs/ai-agent-reputation-score/page.tsx b/web/app/docs/ai-agent-reputation-score/page.tsx
index aa228c9e..260c254a 100644
--- a/web/app/docs/ai-agent-reputation-score/page.tsx
+++ b/web/app/docs/ai-agent-reputation-score/page.tsx
@@ -107,7 +107,7 @@ export default function AiAgentReputationScorePage() {
.
-
+
What goes into the score
@@ -133,7 +133,7 @@ export default function AiAgentReputationScorePage() {
for each term.
-
+
How to read the score
@@ -150,12 +150,12 @@ export default function AiAgentReputationScorePage() {
.
-
+
Frequently asked questions
-
+
Can an agent reputation score be faked?
diff --git a/web/app/docs/do-you-need-an-agent-reputation-system/page.tsx b/web/app/docs/do-you-need-an-agent-reputation-system/page.tsx
index d93d29e7..049bfbb9 100644
--- a/web/app/docs/do-you-need-an-agent-reputation-system/page.tsx
+++ b/web/app/docs/do-you-need-an-agent-reputation-system/page.tsx
@@ -57,7 +57,7 @@ export default function DoYouNeedPage() {
instead of trusting a label after the fact.
-
+
Signs you need one
@@ -69,7 +69,7 @@ export default function DoYouNeedPage() {
-
+
The risk of skipping it
@@ -81,7 +81,7 @@ export default function DoYouNeedPage() {
for the concrete failure mode.
-
+
How to add one
diff --git a/web/app/docs/glossary/page.tsx b/web/app/docs/glossary/page.tsx
index 6348f22e..15abf797 100644
--- a/web/app/docs/glossary/page.tsx
+++ b/web/app/docs/glossary/page.tsx
@@ -147,7 +147,7 @@ export default function GlossaryPage() {
{TERMS.map((t) => (
-
+
{t.href ? (
{t.term}
diff --git a/web/app/docs/how-agentvouch-works/page.tsx b/web/app/docs/how-agentvouch-works/page.tsx
index 58e97867..1b92e3f0 100644
--- a/web/app/docs/how-agentvouch-works/page.tsx
+++ b/web/app/docs/how-agentvouch-works/page.tsx
@@ -52,7 +52,7 @@ export default function HowAgentVouchWorksPage() {
USDC-backed endorsements, author-wide disputes, and payout flows to
make the trust signal costly to fake.
-
+
The core loop
@@ -62,7 +62,7 @@ export default function HowAgentVouchWorksPage() {
If the author behaves badly, a dispute can slash bad backing.
The resulting history stays public and queryable.
-
+
Why the signal matters
diff --git a/web/app/docs/page.tsx b/web/app/docs/page.tsx
index c0ba9a16..6e2d23a5 100644
--- a/web/app/docs/page.tsx
+++ b/web/app/docs/page.tsx
@@ -174,7 +174,7 @@ const { tx } = await oracle.vouch(vouchee, 100_000); // 0.10 USDC in micros`;
{/* Canonical entrypoints */}
-
+
Canonical Agent
Contract
@@ -226,7 +226,7 @@ const { tx } = await oracle.vouch(vouchee, 100_000); // 0.10 USDC in micros`;
{/* CLI */}
-
+
AgentVouch CLI
@@ -268,7 +268,7 @@ const { tx } = await oracle.vouch(vouchee, 100_000); // 0.10 USDC in micros`;
{/* Contract Info */}
-
+
Smart Contract
@@ -305,7 +305,7 @@ const { tx } = await oracle.vouch(vouchee, 100_000); // 0.10 USDC in micros`;
{/* REST API */}
-
+
REST API
@@ -364,7 +364,7 @@ const { tx } = await oracle.vouch(vouchee, 100_000); // 0.10 USDC in micros`;
-
+
Trust Contract
@@ -381,7 +381,7 @@ const { tx } = await oracle.vouch(vouchee, 100_000); // 0.10 USDC in micros`;
-
+
Discovery
Endpoints
@@ -400,7 +400,7 @@ const { tx } = await oracle.vouch(vouchee, 100_000); // 0.10 USDC in micros`;
id="paid-skill-download"
className="rounded-sm border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 p-6 mb-4 scroll-mt-24"
>
-
+
Paid Skill
Download
@@ -475,7 +475,7 @@ const { tx } = await oracle.vouch(vouchee, 100_000); // 0.10 USDC in micros`;
-
+
Agent Publish
Flow
@@ -516,7 +516,7 @@ const { tx } = await oracle.vouch(vouchee, 100_000); // 0.10 USDC in micros`;
{/* Example Code */}
-
+
On-Chain Usage
diff --git a/web/app/docs/skill-md-security/page.tsx b/web/app/docs/skill-md-security/page.tsx
index a9f5aa2f..a4e49d7b 100644
--- a/web/app/docs/skill-md-security/page.tsx
+++ b/web/app/docs/skill-md-security/page.tsx
@@ -48,7 +48,7 @@ export default function SkillMdSecurityPage() {
magically prove a file is safe. It gives the caller a financial trust
record for the author behind the file.
-
+
What to check before install
diff --git a/web/app/docs/trusted-agent-skills/page.tsx b/web/app/docs/trusted-agent-skills/page.tsx
index d06f53ff..f4d352a7 100644
--- a/web/app/docs/trusted-agent-skills/page.tsx
+++ b/web/app/docs/trusted-agent-skills/page.tsx
@@ -51,7 +51,7 @@ export default function TrustedAgentSkillsPage() {
stake if the skill leaks secrets, misuses wallet access, or tells an
agent to run unsafe commands.
-
+
What AgentVouch checks
diff --git a/web/app/docs/verify-ai-agents/page.tsx b/web/app/docs/verify-ai-agents/page.tsx
index 75e0e8f0..11ee440a 100644
--- a/web/app/docs/verify-ai-agents/page.tsx
+++ b/web/app/docs/verify-ai-agents/page.tsx
@@ -52,7 +52,7 @@ export default function VerifyAiAgentsPage() {
whether the agent is registered, whether other participants staked
behind it, and whether there is unresolved dispute history.
-
+
A practical checklist
diff --git a/web/app/docs/what-is-an-agent-reputation-oracle/page.tsx b/web/app/docs/what-is-an-agent-reputation-oracle/page.tsx
index 967c346d..e489ca4c 100644
--- a/web/app/docs/what-is-an-agent-reputation-oracle/page.tsx
+++ b/web/app/docs/what-is-an-agent-reputation-oracle/page.tsx
@@ -76,7 +76,7 @@ export default function AgentReputationOraclePage() {
delegation. That makes it closer to a credit bureau for agents than a
generic marketplace.
-
+
What the oracle answers
diff --git a/web/app/globals.css b/web/app/globals.css
index 6ad8110a..1ac24e1f 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -2,6 +2,14 @@
@plugin "@tailwindcss/typography";
@custom-variant dark (&:where(.dark, .dark *));
+/* Register semantic fonts with Tailwind v4 so variants work in prose too. */
+@theme inline {
+ --font-sans: var(--font-inter), "Inter", sans-serif;
+ --font-title: var(--font-crimson-pro), "Crimson Pro", Georgia, serif;
+ --font-display: var(--font-crimson-pro), "Crimson Pro", Georgia, serif;
+ --font-mono: var(--font-inconsolata), "Inconsolata", monospace;
+}
+
:root {
--background: #f9fafb;
--foreground: #111827;
@@ -75,22 +83,18 @@ body {
font-family: var(--font-inter), "Inter", sans-serif !important;
}
-.font-heading {
- font-family: var(--font-inconsolata), "Inconsolata", monospace !important;
-}
-
+.font-title,
.font-display {
font-family: var(--font-crimson-pro), "Crimson Pro", Georgia, serif !important;
- font-weight: 400 !important;
}
-/* Inter is the readable face for descriptions, subtitles, and long-form copy.
- Keep the class name for existing article/skill surfaces. */
-.font-article {
- font-family: var(--font-inter), "Inter", sans-serif !important;
+.font-display {
+ font-weight: 400 !important;
}
-.font-article :where(code, pre, kbd, samp) {
+/* Keep technical content visibly distinct from readable UI copy. */
+.font-mono,
+.font-sans :where(code, pre, kbd, samp) {
font-family: var(--font-inconsolata), "Inconsolata", monospace !important;
}
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 77cc6778..c3bb95a6 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -1,4 +1,5 @@
import Link from "next/link";
+import { FeaturedSkillsCarousel } from "@/components/FeaturedSkillsCarousel";
import TypewriterText from "@/components/TypewriterText";
import { HomeInstallCard } from "@/components/HomeInstallCard";
import SkillPreviewCard from "@/components/SkillPreviewCard";
@@ -14,11 +15,6 @@ import {
FiArrowRight,
FiCheckCircle,
FiExternalLink,
- FiGitBranch,
- FiLayers,
- FiShield,
- FiShoppingBag,
- FiTerminal,
FiTrendingUp,
FiZap,
} from "react-icons/fi";
@@ -170,7 +166,7 @@ export default async function Home() {
See all →
-
+
{featuredSkills.map((skill) => {
const downloads =
(skill.total_installs ?? 0) + (skill.total_downloads ?? 0);
@@ -197,7 +193,7 @@ export default async function Home() {
/>
);
})}
-
+
)}
@@ -294,56 +290,6 @@ export default async function Home() {
-
-
- {[
- {
- icon:
,
- label: "Stake-Weighted Vouching",
- desc: "economic skin-in-the-game",
- },
- {
- icon:
,
- label: "Solana / Anchor",
- desc: "fast, low-cost transactions",
- },
- {
- icon:
,
- label: "Marketplace",
- desc: "publish, buy & sell skills on-chain",
- },
- {
- icon:
,
- label: "Dispute Resolution",
- desc: "on-chain slashing",
- },
- {
- icon:
,
- label: "skill.md",
- desc: "single-file agent integration",
- },
- {
- icon:
,
- label: "Open Source",
- desc: "MIT licensed",
- },
- ].map((f) => (
-
-
- {f.icon} {" "}
- {f.label}
-
-
- {f.desc}
-
-
- ))}
-
-
-
diff --git a/web/app/skills/MarketplaceClient.tsx b/web/app/skills/MarketplaceClient.tsx
index 9303e572..05264827 100644
--- a/web/app/skills/MarketplaceClient.tsx
+++ b/web/app/skills/MarketplaceClient.tsx
@@ -27,6 +27,7 @@ import {
FiAlertTriangle,
FiBookOpen,
FiBox,
+ FiChevronDown,
FiCheckCircle,
FiDownload,
FiEdit2,
@@ -290,11 +291,14 @@ export default function MarketplaceClient({
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedTag, setSelectedTag] = useState(null);
const [sort, setSort] = useState("trusted");
+ // `page` tracks the highest browse page currently loaded into the list.
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(
Math.max(1, Math.ceil(initialTotal / pageSize))
);
const [total, setTotal] = useState(initialTotal);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [loadMoreError, setLoadMoreError] = useState(null);
// The query state the server snapshot covers. While the live state still
// matches it, the browse fetch is redundant; any change (search, sort, page,
// wallet) breaks the match and fetches as usual. A plain ref flag would
@@ -388,37 +392,77 @@ export default function MarketplaceClient({
[publicKey]
);
- const fetchSkills = useCallback(async () => {
- const requestId = ++browseRequestRef.current;
- setLoading(true);
- try {
- const params = new URLSearchParams();
- if (debouncedSearch) params.set("q", debouncedSearch);
- if (selectedTag) params.set("tags", selectedTag);
- params.set("mode", "fast");
- params.set("sort", sort);
- params.set("page", String(page));
- params.set("pageSize", String(MARKETPLACE_PAGE_SIZE));
+ const fetchSkills = useCallback(
+ async (options?: { append?: boolean; pageToFetch?: number }) => {
+ const append = options?.append ?? page > 1;
+ const pageToFetch = options?.pageToFetch ?? page;
+ const requestId = ++browseRequestRef.current;
- const res = await fetch(`/api/skills?${params}`);
- if (!res.ok) throw new Error("Failed to fetch skills");
- const data: ApiResponse = await res.json();
- if (browseRequestRef.current !== requestId) return;
+ if (append) {
+ setLoadingMore(true);
+ setLoadMoreError(null);
+ } else {
+ setLoading(true);
+ setLoadingMore(false);
+ setLoadMoreError(null);
+ }
- setSkills(data.skills);
- setTotalPages(data.pagination.totalPages);
- setTotal(data.pagination.total);
- void hydrateVisibleSkills(data.skills);
- } catch (err) {
- if (browseRequestRef.current !== requestId) return;
- console.error("Error fetching skills:", err);
- setSkills([]);
- } finally {
- if (browseRequestRef.current === requestId) {
- setLoading(false);
+ try {
+ const params = new URLSearchParams();
+ if (debouncedSearch) params.set("q", debouncedSearch);
+ if (selectedTag) params.set("tags", selectedTag);
+ params.set("mode", "fast");
+ params.set("sort", sort);
+ params.set("page", String(pageToFetch));
+ params.set("pageSize", String(MARKETPLACE_PAGE_SIZE));
+
+ const res = await fetch(`/api/skills?${params}`);
+ if (!res.ok) throw new Error("Failed to fetch skills");
+ const data: ApiResponse = await res.json();
+ if (browseRequestRef.current !== requestId) return;
+
+ if (append) {
+ setSkills((previousSkills) => {
+ const existingIds = new Set(
+ previousSkills.map((skill) => skill.id)
+ );
+ const refreshedSkills = new Map(
+ data.skills.map((skill) => [skill.id, skill])
+ );
+
+ return [
+ ...previousSkills.map(
+ (skill) => refreshedSkills.get(skill.id) ?? skill
+ ),
+ ...data.skills.filter((skill) => !existingIds.has(skill.id)),
+ ];
+ });
+ } else {
+ setSkills(data.skills);
+ }
+ setTotalPages(data.pagination.totalPages);
+ setTotal(data.pagination.total);
+ void hydrateVisibleSkills(data.skills);
+ } catch (err) {
+ if (browseRequestRef.current !== requestId) return;
+ console.error("Error fetching skills:", err);
+ if (append) {
+ setLoadMoreError("Couldn't load more skills. Try again.");
+ } else {
+ setSkills([]);
+ }
+ } finally {
+ if (browseRequestRef.current === requestId) {
+ if (append) {
+ setLoadingMore(false);
+ } else {
+ setLoading(false);
+ }
+ }
}
- }
- }, [debouncedSearch, hydrateVisibleSkills, page, selectedTag, sort]);
+ },
+ [debouncedSearch, hydrateVisibleSkills, page, selectedTag, sort]
+ );
const loadFeed = useCallback(async () => {
try {
@@ -593,6 +637,18 @@ export default function MarketplaceClient({
setPage(1);
}, []);
+ const handleLoadMore = useCallback(() => {
+ if (loadingMore || (!loadMoreError && page >= totalPages)) return;
+
+ if (loadMoreError) {
+ void fetchSkills({ append: true, pageToFetch: page });
+ return;
+ }
+
+ setLoadingMore(true);
+ setPage((currentPage) => Math.min(totalPages, currentPage + 1));
+ }, [fetchSkills, loadMoreError, loadingMore, page, totalPages]);
+
const handlePurchase = async (
listingPubkey: Address,
authorKey: Address,
@@ -725,12 +781,12 @@ export default function MarketplaceClient({
public_slug: item.publicSlug,
public_author_slug: item.publicAuthorSlug,
})}
- className="font-display text-sm text-gray-900 hover:text-[var(--sea-accent)] dark:text-white"
+ className="font-title text-sm text-gray-900 hover:text-[var(--sea-accent)] dark:text-white"
>
{item.skillName}
) : (
-
+
{item.skillName}
)}
@@ -820,7 +876,7 @@ export default function MarketplaceClient({
setActiveTab(tab.key)}
- className={`px-4 py-2 font-display text-[17px] whitespace-nowrap transition border-b-2 -mb-[2px] ${
+ className={`px-4 py-2 font-sans text-[17px] whitespace-nowrap transition border-b-2 -mb-[2px] ${
activeTab === tab.key
? "border-[var(--sea-accent)] text-gray-900 dark:text-white"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
@@ -983,26 +1039,50 @@ export default function MarketplaceClient({
})}
- {/* Pagination */}
+ {/* Append-only pagination */}
{totalPages > 1 && (
-
-
setPage(Math.max(1, page - 1))}
- disabled={page === 1}
- className="px-3 py-1.5 rounded-sm text-sm border border-gray-200 dark:border-gray-700 disabled:opacity-40 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
+
+ {loadMoreError && (
+
+ {loadMoreError}
+
+ )}
+ {loadingMore || page < totalPages || loadMoreError ? (
+
+ {loadingMore ? (
+ <>
+
+ Loading more
+ >
+ ) : loadMoreError ? (
+ "Try again"
+ ) : (
+ <>
+ View more
+
+ >
+ )}
+
+ ) : (
+
+ You've reached the end
+
+ )}
+
- Previous
-
-
- Page {page} of {totalPages}
+ Showing {skills.length} of {total} skills
- setPage(Math.min(totalPages, page + 1))}
- disabled={page === totalPages}
- className="px-3 py-1.5 rounded-sm text-sm border border-gray-200 dark:border-gray-700 disabled:opacity-40 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
- >
- Next
-
)}
>
diff --git a/web/app/skills/[id]/SkillDetailClient.tsx b/web/app/skills/[id]/SkillDetailClient.tsx
index b8b1ee38..9e6f6c67 100644
--- a/web/app/skills/[id]/SkillDetailClient.tsx
+++ b/web/app/skills/[id]/SkillDetailClient.tsx
@@ -2200,7 +2200,7 @@ export default function SkillDetailPage({
);
return (
-
+
{buyerCardAccessEnabled && (
)}
@@ -2228,7 +2228,7 @@ export default function SkillDetailPage({
{skill.name}
{skill.description && (
-
+
{skill.description}
)}
@@ -2355,7 +2355,7 @@ export default function SkillDetailPage({
{displaySummaryLine &&
displaySummaryLine !== skill.description && (
-
+
{displaySummaryLine}
)}
@@ -2407,7 +2407,7 @@ export default function SkillDetailPage({
Paid content
-
+
{buyerHasPurchased
? "Purchase verified. Use Sign & Download in the purchase panel to retrieve SKILL.md."
: `Complete the ${usdcPriceLabel} purchase, then sign with the buyer wallet to retrieve SKILL.md.`}
diff --git a/web/components/AppFooter.tsx b/web/components/AppFooter.tsx
index 8f8e6849..39d0f830 100644
--- a/web/components/AppFooter.tsx
+++ b/web/components/AppFooter.tsx
@@ -11,7 +11,7 @@ export function AppFooter() {
AgentVouch
diff --git a/web/components/AppNavbar.tsx b/web/components/AppNavbar.tsx
index 7863c401..d95f598f 100644
--- a/web/components/AppNavbar.tsx
+++ b/web/components/AppNavbar.tsx
@@ -47,7 +47,7 @@ export const navItems: NavItem[] = [
function navLinkClass(isActive: boolean) {
return [
- "font-display text-[15px] px-2 py-1 transition whitespace-nowrap",
+ "font-sans text-[15px] px-2 py-1 transition whitespace-nowrap",
isActive
? "text-gray-900 dark:text-white"
: "text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white",
@@ -56,7 +56,7 @@ function navLinkClass(isActive: boolean) {
function mobileNavLinkClass(isActive: boolean) {
return [
- "flex items-center justify-between rounded-sm border px-3 py-2 font-display text-[15px] transition",
+ "flex items-center justify-between rounded-sm border px-3 py-2 font-sans text-[15px] transition",
isActive
? "border-[var(--sea-accent-border)] bg-[var(--sea-accent-soft)] text-[var(--sea-accent-strong)]"
: "border-transparent text-gray-600 hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:border-gray-800 dark:hover:bg-gray-900 dark:hover:text-white",
diff --git a/web/components/FeaturedSkillsCarousel.tsx b/web/components/FeaturedSkillsCarousel.tsx
new file mode 100644
index 00000000..de17aa3c
--- /dev/null
+++ b/web/components/FeaturedSkillsCarousel.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { Children, useEffect, useState, type ReactNode } from "react";
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+ type CarouselApi,
+} from "@/components/ui/carousel";
+
+interface FeaturedSkillsCarouselProps {
+ children: ReactNode;
+}
+
+export function FeaturedSkillsCarousel({
+ children,
+}: FeaturedSkillsCarouselProps) {
+ const items = Children.toArray(children);
+ const [api, setApi] = useState
();
+ const [current, setCurrent] = useState(0);
+ const [snapCount, setSnapCount] = useState(0);
+
+ useEffect(() => {
+ if (!api) return;
+
+ const updateCarousel = () => {
+ setCurrent(api.selectedScrollSnap());
+ setSnapCount(api.scrollSnapList().length);
+ };
+ updateCarousel();
+ api.on("reInit", updateCarousel);
+ api.on("select", updateCarousel);
+
+ return () => {
+ api.off("reInit", updateCarousel);
+ api.off("select", updateCarousel);
+ };
+ }, [api]);
+
+ if (items.length === 0) return null;
+
+ return (
+
+
1 }}
+ aria-label="Featured skills"
+ >
+
+ {items.map((item, index) => (
+
+ {item}
+
+ ))}
+
+ {snapCount > 1 && (
+ <>
+
+
+ >
+ )}
+
+
+ {snapCount > 1 && (
+
+
+ {Array.from({ length: snapCount }, (_, index) => {
+ const isCurrent = current === index;
+
+ return (
+ api?.scrollTo(index)}
+ className={`h-1.5 rounded-full transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--lobster-accent-border)] focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950 ${
+ isCurrent
+ ? "w-6 bg-[var(--lobster-accent)]"
+ : "w-1.5 bg-gray-300 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600"
+ }`}
+ />
+ );
+ })}
+
+
+ {String(current + 1).padStart(2, "0")} /{" "}
+ {String(snapCount).padStart(2, "0")}
+
+
+ )}
+
+ );
+}
diff --git a/web/components/MarkdownRenderer.tsx b/web/components/MarkdownRenderer.tsx
index 625e15d7..030f4f7e 100644
--- a/web/components/MarkdownRenderer.tsx
+++ b/web/components/MarkdownRenderer.tsx
@@ -43,7 +43,7 @@ function FrontmatterBlock({ value }: { value: string }) {
if (!value) return null;
return (
-
+
skill metadata
@@ -84,8 +84,8 @@ export default function MarkdownRenderer({
const variantClass =
variant === "skill"
? [
- "font-heading text-[13px] leading-6 text-gray-700 dark:text-gray-300",
- "prose-headings:font-heading prose-headings:font-normal prose-headings:tracking-normal",
+ "font-sans text-[13px] leading-6 text-gray-700 dark:text-gray-300",
+ "prose-headings:font-display prose-headings:font-normal prose-headings:tracking-normal",
"prose-h1:mt-6 prose-h1:mb-3 prose-h1:text-[22px] prose-h1:leading-tight",
"prose-h2:mt-6 prose-h2:mb-2 prose-h2:text-[16px] prose-h2:leading-snug",
"prose-h3:mt-5 prose-h3:mb-2 prose-h3:text-[14px] prose-h3:leading-snug",
@@ -96,8 +96,8 @@ export default function MarkdownRenderer({
: "";
const inlineCodeClass =
variant === "skill"
- ? "rounded-sm border border-[var(--lobster-accent-border)] bg-[var(--lobster-accent-soft)] px-1.5 py-0.5 font-heading text-[0.92em] text-[var(--lobster-accent)]"
- : "bg-gray-100 dark:bg-gray-800 rounded px-1.5 py-0.5 text-sm text-pink-600 dark:text-pink-400";
+ ? "rounded-sm border border-[var(--lobster-accent-border)] bg-[var(--lobster-accent-soft)] px-1.5 py-0.5 font-mono text-[0.92em] text-[var(--lobster-accent)]"
+ : "bg-gray-100 dark:bg-gray-800 rounded px-1.5 py-0.5 font-mono text-sm text-pink-600 dark:text-pink-400";
return (
-
+
Full content unlocks after purchase
diff --git a/web/components/SkillPreviewCard.tsx b/web/components/SkillPreviewCard.tsx
index 6d3896ea..a80f14c0 100644
--- a/web/components/SkillPreviewCard.tsx
+++ b/web/components/SkillPreviewCard.tsx
@@ -493,7 +493,7 @@ export default function SkillPreviewCard({
{/* Description — readable body face */}
{displayDescription && (
{displayDescription}
diff --git a/web/components/ui/carousel.tsx b/web/components/ui/carousel.tsx
new file mode 100644
index 00000000..a0ad0fb9
--- /dev/null
+++ b/web/components/ui/carousel.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+import * as React from "react";
+import useEmblaCarousel from "embla-carousel-react";
+import type { UseEmblaCarouselType } from "embla-carousel-react";
+import { FiChevronLeft, FiChevronRight } from "react-icons/fi";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: "horizontal" | "vertical";
+ setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: CarouselApi;
+ opts?: CarouselOptions;
+ orientation: "horizontal" | "vertical";
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+};
+
+const CarouselContext = React.createContext(null);
+
+function joinClasses(
+ ...classes: Array
+): string {
+ return classes.filter(Boolean).join(" ");
+}
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+const Carousel = React.forwardRef<
+ HTMLDivElement,
+ CarouselProps & React.HTMLAttributes
+>(
+ (
+ {
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ onKeyDown,
+ ...props
+ },
+ ref
+ ) => {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((carouselApi: CarouselApi) => {
+ if (!carouselApi) return;
+
+ setCanScrollPrev(carouselApi.canScrollPrev());
+ setCanScrollNext(carouselApi.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ scrollNext();
+ }
+
+ onKeyDown?.(event);
+ },
+ [onKeyDown, scrollNext, scrollPrev]
+ );
+
+ React.useEffect(() => {
+ if (!api) return;
+
+ onSelect(api);
+ api.on("reInit", onSelect);
+ api.on("select", onSelect);
+
+ return () => {
+ api.off("reInit", onSelect);
+ api.off("select", onSelect);
+ };
+ }, [api, onSelect]);
+
+ React.useEffect(() => {
+ if (api && setApi) setApi(api);
+ }, [api, setApi]);
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+Carousel.displayName = "Carousel";
+
+const CarouselContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+});
+CarouselContent.displayName = "CarouselContent";
+
+const CarouselItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+});
+CarouselItem.displayName = "CarouselItem";
+
+const CarouselPrevious = React.forwardRef<
+ HTMLButtonElement,
+ React.ButtonHTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+ {children ?? }
+
+ );
+});
+CarouselPrevious.displayName = "CarouselPrevious";
+
+const CarouselNext = React.forwardRef<
+ HTMLButtonElement,
+ React.ButtonHTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+ {children ?? }
+
+ );
+});
+CarouselNext.displayName = "CarouselNext";
+
+export {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+};
+export type { CarouselApi };
diff --git a/web/package.json b/web/package.json
index 08ef41d4..03fdb091 100644
--- a/web/package.json
+++ b/web/package.json
@@ -41,6 +41,7 @@
"8004-solana": "^0.8.3",
"ai": "^6.0.191",
"codama": "^1.5.1",
+ "embla-carousel-react": "^8.6.0",
"highlight.js": "^11.11.1",
"next": "16.1.6",
"next-themes": "^0.4.6",
diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts
index 808202ee..a4f4b1ee 100644
--- a/web/tailwind.config.ts
+++ b/web/tailwind.config.ts
@@ -10,7 +10,7 @@ const config: Config = {
extend: {
fontFamily: {
sans: ["var(--font-inter)", '"Inter"', "sans-serif"],
- heading: ["var(--font-inconsolata)", '"Inconsolata"', "monospace"],
+ title: ["var(--font-crimson-pro)", '"Crimson Pro"', "serif"],
display: ["var(--font-crimson-pro)", '"Crimson Pro"', "serif"],
mono: ["var(--font-inconsolata)", '"Inconsolata"', "monospace"],
},