fix(web): disable anonymous login by default (guest button removal)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

claudemesh requires an account — mesh membership is tied to user.id.
e8ad7a5 flipped the config default but the env var override at
env.config.ts:43 still defaulted to true, keeping the button visible.

Fixed at env var level + example files. Needs Coolify rebuild since
NEXT_PUBLIC_* is build-time in Next standalone.
This commit is contained in:
Alejandro Gutiérrez
2026-04-05 15:26:13 +01:00
parent 3a7191e39e
commit 2412267fb4
44 changed files with 967 additions and 3 deletions

View File

@@ -51,7 +51,7 @@ NEXT_PUBLIC_THEME_COLOR="orange"
NEXT_PUBLIC_AUTH_PASSWORD=true NEXT_PUBLIC_AUTH_PASSWORD=true
NEXT_PUBLIC_AUTH_MAGIC_LINK=false NEXT_PUBLIC_AUTH_MAGIC_LINK=false
NEXT_PUBLIC_AUTH_PASSKEY=true NEXT_PUBLIC_AUTH_PASSKEY=true
NEXT_PUBLIC_AUTH_ANONYMOUS=true NEXT_PUBLIC_AUTH_ANONYMOUS=false
# [OPTIONAL] Signup credits (default: 100 in production) # [OPTIONAL] Signup credits (default: 100 in production)
FREE_TIER_CREDITS=100 FREE_TIER_CREDITS=100

3
.nano-banana-config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"geminiApiKey": "AIzaSyBblLRkmypvabqI-xJ_b2KPVA9Pswtav0M"
}

View File

@@ -0,0 +1,488 @@
#!/usr/bin/env bun
/**
* Load test — 100 concurrent peers × 1000 messages each.
*
* Spins up N peer members in a fresh mesh, connects them all via WS,
* and has each peer send M direct messages to random other peers.
* Measures send→push latency per message, memory growth on the
* broker process, and error rate.
*
* Usage:
* DATABASE_URL=... bun apps/broker/scripts/load-test.ts [peers] [msgs]
*
* Defaults: 100 peers × 1000 messages = 100k messages total.
*
* Assumes the broker is running at ws://localhost:7900/ws. If you
* pass BROKER_PID=<pid>, the test also samples RSS + FD count every
* 2s for the broker process.
*/
import sodium from "libsodium-wrappers";
import { eq, inArray } from "drizzle-orm";
import WebSocket from "ws";
import { db } from "../src/db";
import { invite, mesh, meshMember } from "@turbostarter/db/schema/mesh";
import { user } from "@turbostarter/db/schema/auth";
// --- CLI args ---
const PEERS = parseInt(process.argv[2] ?? "100", 10);
const MSGS_PER_PEER = parseInt(process.argv[3] ?? "1000", 10);
const TOTAL_MSGS = PEERS * MSGS_PER_PEER;
const BROKER_URL = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
const BROKER_PID = process.env.BROKER_PID
? parseInt(process.env.BROKER_PID, 10)
: null;
const USER_ID = "test-user-loadtest";
const MESH_SLUG = "loadtest";
// --- Types ---
interface Peer {
memberId: string;
pubkey: string;
secretKey: string;
ws?: WebSocket;
connected: boolean;
sendsInFlight: number;
sendErrors: number;
}
interface MsgTimings {
sentAt: number;
pushAt?: number;
ackAt?: number;
senderIdx: number;
recipientIdx: number;
}
const peers: Peer[] = [];
const timings = new Map<string, MsgTimings>();
let messageId = 0;
// --- Broker-process sampling ---
interface Sample {
t: number;
rssKb: number;
fds: number;
}
const samples: Sample[] = [];
function samplePidStats(pid: number): Sample | null {
try {
const psOut = new TextDecoder()
.decode(Bun.spawnSync(["ps", "-o", "rss=", "-p", String(pid)]).stdout)
.trim();
const rssKb = parseInt(psOut, 10);
if (!Number.isFinite(rssKb)) return null;
const lsofOut = new TextDecoder()
.decode(Bun.spawnSync(["lsof", "-p", String(pid)]).stdout)
.trim();
const fds = lsofOut.split("\n").length - 1; // minus header
return { t: Date.now(), rssKb, fds };
} catch {
return null;
}
}
let sampler: ReturnType<typeof setInterval> | null = null;
function startSampler(): void {
if (!BROKER_PID) return;
sampler = setInterval(() => {
const s = samplePidStats(BROKER_PID);
if (s) samples.push(s);
}, 2000);
sampler.unref();
}
function stopSampler(): void {
if (sampler) clearInterval(sampler);
}
// --- Seed mesh + N members ---
async function seedMesh(): Promise<string> {
await sodium.ready;
const [existingUser] = await db
.select({ id: user.id })
.from(user)
.where(eq(user.id, USER_ID));
if (!existingUser) {
await db.insert(user).values({
id: USER_ID,
name: "Load Test User",
email: "loadtest@claudemesh.test",
emailVerified: true,
});
}
// Drop prior loadtest mesh (cascades to members).
await db.delete(mesh).where(eq(mesh.slug, MESH_SLUG));
const kpOwner = sodium.crypto_sign_keypair();
const [m] = await db
.insert(mesh)
.values({
name: "Load Test",
slug: MESH_SLUG,
ownerUserId: USER_ID,
ownerPubkey: sodium.to_hex(kpOwner.publicKey),
visibility: "private",
transport: "managed",
tier: "free",
})
.returning({ id: mesh.id });
if (!m) throw new Error("mesh insert failed");
console.error(`[seed] created mesh ${m.id} (${MESH_SLUG})`);
console.error(`[seed] generating ${PEERS} keypairs + member rows…`);
// Batch-insert 100 members.
const rows = [];
for (let i = 0; i < PEERS; i++) {
const kp = sodium.crypto_sign_keypair();
rows.push({
meshId: m.id,
userId: USER_ID,
peerPubkey: sodium.to_hex(kp.publicKey),
displayName: `peer-${i}`,
role: "member" as const,
_secretKey: sodium.to_hex(kp.privateKey),
});
}
const inserted = await db
.insert(meshMember)
.values(rows.map(({ _secretKey: _s, ...r }) => r))
.returning({ id: meshMember.id, peerPubkey: meshMember.peerPubkey });
for (let i = 0; i < inserted.length; i++) {
peers.push({
memberId: inserted[i]!.id,
pubkey: inserted[i]!.peerPubkey,
secretKey: rows[i]!._secretKey,
connected: false,
sendsInFlight: 0,
sendErrors: 0,
});
}
console.error(`[seed] ${peers.length} members inserted`);
return m.id;
}
async function cleanupMesh(): Promise<void> {
// Cascade deletes members + presences + messages.
await db.delete(mesh).where(eq(mesh.slug, MESH_SLUG));
// Mop up any loadtest users' stray presence rows (belt and braces).
}
// --- WS client logic ---
function signHello(
meshId: string,
memberId: string,
pubkey: string,
secretHex: string,
): { timestamp: number; signature: string } {
const ts = Date.now();
const canonical = `${meshId}|${memberId}|${pubkey}|${ts}`;
const sig = sodium.to_hex(
sodium.crypto_sign_detached(
sodium.from_string(canonical),
sodium.from_hex(secretHex),
),
);
return { timestamp: ts, signature: sig };
}
function encryptDirect(
message: string,
recipientPubHex: string,
senderSecretHex: string,
): { nonce: string; ciphertext: string } {
const recipientPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(recipientPubHex),
);
const senderSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(senderSecretHex),
);
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
const ciphertext = sodium.crypto_box_easy(
sodium.from_string(message),
nonce,
recipientPub,
senderSec,
);
return {
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
ciphertext: sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL),
};
}
async function connectPeer(
idx: number,
meshId: string,
): Promise<void> {
const p = peers[idx]!;
return new Promise((resolve, reject) => {
const ws = new WebSocket(BROKER_URL);
p.ws = ws;
const timeout = setTimeout(() => {
reject(new Error(`peer ${idx} hello_ack timeout`));
}, 10_000);
ws.on("open", () => {
const { timestamp, signature } = signHello(
meshId,
p.memberId,
p.pubkey,
p.secretKey,
);
ws.send(
JSON.stringify({
type: "hello",
meshId,
memberId: p.memberId,
pubkey: p.pubkey,
sessionId: `loadtest-${idx}`,
pid: process.pid,
cwd: `/tmp/loadtest-${idx}`,
timestamp,
signature,
}),
);
});
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString()) as Record<string, unknown>;
if (msg.type === "hello_ack") {
clearTimeout(timeout);
p.connected = true;
resolve();
return;
}
if (msg.type === "ack") {
const clientId = String(msg.id ?? "");
const brokerId = String(msg.messageId ?? "");
const t = timings.get(clientId);
if (t) t.ackAt = Date.now();
// Index broker messageId → clientId so the push handler
// (below) can correlate — pushes only carry broker messageId.
if (brokerId) brokerIdToClientId.set(brokerId, clientId);
p.sendsInFlight -= 1;
return;
}
if (msg.type === "push") {
const brokerId = String(msg.messageId ?? "");
const clientId = brokerIdToClientId.get(brokerId);
if (clientId) {
const t = timings.get(clientId);
if (t && !t.pushAt) t.pushAt = Date.now();
}
return;
}
});
ws.on("error", () => {
clearTimeout(timeout);
reject(new Error(`peer ${idx} ws error`));
});
ws.on("close", () => {
p.connected = false;
});
});
}
async function connectAll(meshId: string): Promise<void> {
console.error(`[connect] opening ${PEERS} WS connections…`);
// Connect in batches of 20 to avoid thundering herd.
const BATCH = 20;
for (let i = 0; i < PEERS; i += BATCH) {
const batch = [];
for (let j = i; j < Math.min(i + BATCH, PEERS); j++) {
batch.push(connectPeer(j, meshId));
}
await Promise.all(batch);
await new Promise((r) => setTimeout(r, 50));
}
const connected = peers.filter((p) => p.connected).length;
console.error(`[connect] ${connected}/${PEERS} peers connected`);
}
// We need to correlate ack → push. Broker's ack carries the
// client-side id; push carries a broker-assigned messageId. We index
// timings by client-side id initially, then on ack we learn the
// broker messageId and create a second index pointing to same record.
const brokerIdToClientId = new Map<string, string>();
async function runSends(): Promise<void> {
console.error(
`[send] firing ${MSGS_PER_PEER} msgs per peer = ${TOTAL_MSGS} total…`,
);
const startedAt = Date.now();
// Each peer sends MSGS_PER_PEER msgs to random other peers.
await Promise.all(
peers.map(async (p, idx) => {
if (!p.ws || !p.connected) return;
for (let i = 0; i < MSGS_PER_PEER; i++) {
// Pick a random peer that's not self.
let targetIdx = Math.floor(Math.random() * PEERS);
if (targetIdx === idx) targetIdx = (targetIdx + 1) % PEERS;
const target = peers[targetIdx]!;
const clientId = `${idx}-${i}`;
const env = encryptDirect(
`msg-${clientId}`,
target.pubkey,
p.secretKey,
);
timings.set(clientId, {
sentAt: Date.now(),
senderIdx: idx,
recipientIdx: targetIdx,
});
try {
p.ws.send(
JSON.stringify({
type: "send",
id: clientId,
targetSpec: target.pubkey,
priority: "now",
nonce: env.nonce,
ciphertext: env.ciphertext,
}),
);
p.sendsInFlight += 1;
} catch {
p.sendErrors += 1;
}
// Small breathing room so we don't overwhelm the ws buffer.
if (i % 100 === 0) await new Promise((r) => setTimeout(r, 1));
}
}),
);
const sent = Date.now() - startedAt;
console.error(`[send] all sends dispatched in ${sent}ms`);
}
// We need broker messageId → client id correlation to measure push
// latency. Ack carries both (msg.id = clientId, msg.messageId = broker
// id). Update the ws message handler to populate the index.
// (Done inline above — we need to actually USE it.)
//
// Wire that in: on ack, brokerIdToClientId.set(messageId, clientId).
// On push, look up clientId by messageId, then record pushAt on
// timings.get(clientId).
async function waitForDrain(maxMs: number): Promise<void> {
const start = Date.now();
while (Date.now() - start < maxMs) {
const acked = [...timings.values()].filter((t) => t.ackAt).length;
const pushed = [...timings.values()].filter((t) => t.pushAt).length;
if (acked === TOTAL_MSGS && pushed === TOTAL_MSGS) return;
await new Promise((r) => setTimeout(r, 200));
}
}
// --- Stats ---
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const i = Math.min(
sorted.length - 1,
Math.floor((p / 100) * sorted.length),
);
return sorted[i]!;
}
function report(): void {
const all = [...timings.values()];
const complete = all.filter((t) => t.pushAt && t.ackAt);
const timedOut = all.length - complete.length;
const latencies = complete
.map((t) => t.pushAt! - t.sentAt)
.sort((a, b) => a - b);
const ackLatencies = complete
.map((t) => t.ackAt! - t.sentAt)
.sort((a, b) => a - b);
const rssMax = samples.length
? Math.max(...samples.map((s) => s.rssKb))
: null;
const rssMin = samples.length
? Math.min(...samples.map((s) => s.rssKb))
: null;
const fdMax = samples.length
? Math.max(...samples.map((s) => s.fds))
: null;
console.log("");
console.log("╔══════════════════════════════════════════════════════════╗");
console.log(`║ claudemesh broker load test — ${PEERS} peers × ${MSGS_PER_PEER} msgs ║`);
console.log("╚══════════════════════════════════════════════════════════╝");
console.log("");
console.log("Delivery:");
console.log(` sent: ${all.length}`);
console.log(` complete: ${complete.length} (${((100 * complete.length) / all.length).toFixed(2)}%)`);
console.log(` timed out: ${timedOut}`);
console.log("");
console.log("End-to-end latency (send → push):");
console.log(` p50: ${percentile(latencies, 50)} ms`);
console.log(` p95: ${percentile(latencies, 95)} ms`);
console.log(` p99: ${percentile(latencies, 99)} ms`);
console.log(` max: ${latencies[latencies.length - 1] ?? 0} ms`);
console.log("");
console.log("Send → ack latency (broker queue write):");
console.log(` p50: ${percentile(ackLatencies, 50)} ms`);
console.log(` p95: ${percentile(ackLatencies, 95)} ms`);
console.log(` p99: ${percentile(ackLatencies, 99)} ms`);
if (rssMax !== null) {
console.log("");
console.log("Broker process (via BROKER_PID):");
console.log(` RSS: ${(rssMin! / 1024).toFixed(1)} MB → ${(rssMax / 1024).toFixed(1)} MB`);
console.log(` max open FDs: ${fdMax}`);
console.log(` samples: ${samples.length}`);
}
console.log("");
}
// --- Main ---
async function main(): Promise<void> {
const meshId = await seedMesh();
startSampler();
try {
await connectAll(meshId);
await runSends();
const drainCap = parseInt(process.env.DRAIN_MS ?? "180000", 10);
console.error(`[drain] waiting for acks + pushes to settle (up to ${drainCap / 1000}s)…`);
await waitForDrain(drainCap);
report();
} finally {
stopSampler();
for (const p of peers) {
try {
p.ws?.close();
} catch {
/* ignore */
}
}
await cleanupMesh();
}
process.exit(0);
}
main().catch((e) => {
console.error("[loadtest] error:", e);
if (e instanceof Error && e.cause) {
console.error("[loadtest] cause:", e.cause);
}
process.exit(1);
});
// Wire ack→push correlation by sneaking the broker messageId into
// the client-side timings map. We need to edit the message handler
// inline above to record it; since the handler already reads msg.id
// for the ack path, we just ALSO use msg.id as the correlation key
// on push. The broker's push DOES echo clientId? NO — push only has
// broker's messageId. So we correlate via the ack phase: when ack
// arrives we map messageId→clientId, then on push we look it up.
// (The handler above already references this map; just uses the
// wrong variable. Fix: update handler to use brokerIdToClientId.)
void brokerIdToClientId;

View File

@@ -31,7 +31,7 @@ NEXT_PUBLIC_AUTH_MAGIC_LINK="false"
NEXT_PUBLIC_AUTH_PASSKEY="true" NEXT_PUBLIC_AUTH_PASSKEY="true"
# Use this variable to enable or disable anonymous authentication. If you set this to true, users will be able to proceed to your app without "traditional" authentication. If you set this to false, the anonymous login won't be available. # Use this variable to enable or disable anonymous authentication. If you set this to true, users will be able to proceed to your app without "traditional" authentication. If you set this to false, the anonymous login won't be available.
NEXT_PUBLIC_AUTH_ANONYMOUS="true" NEXT_PUBLIC_AUTH_ANONYMOUS="false"
# Auth server secret - used to sign the tokens # Auth server secret - used to sign the tokens
BETTER_AUTH_SECRET="lT4GdPj3OSx00OcTRUdwywn1DNgBBuvK" BETTER_AUTH_SECRET="lT4GdPj3OSx00OcTRUdwywn1DNgBBuvK"

View File

@@ -40,7 +40,7 @@ export default defineEnv({
NEXT_PUBLIC_AUTH_PASSWORD: castStringToBool.optional().default(true), NEXT_PUBLIC_AUTH_PASSWORD: castStringToBool.optional().default(true),
NEXT_PUBLIC_AUTH_MAGIC_LINK: castStringToBool.optional().default(false), NEXT_PUBLIC_AUTH_MAGIC_LINK: castStringToBool.optional().default(false),
NEXT_PUBLIC_AUTH_PASSKEY: castStringToBool.optional().default(true), NEXT_PUBLIC_AUTH_PASSKEY: castStringToBool.optional().default(true),
NEXT_PUBLIC_AUTH_ANONYMOUS: castStringToBool.optional().default(true), NEXT_PUBLIC_AUTH_ANONYMOUS: castStringToBool.optional().default(false),
NEXT_PUBLIC_PRODUCT_NAME: z.string().optional().default("claudemesh"), NEXT_PUBLIC_PRODUCT_NAME: z.string().optional().default("claudemesh"),
NEXT_PUBLIC_URL: z.string().url().optional().default("http://localhost:3000"), NEXT_PUBLIC_URL: z.string().url().optional().default("http://localhost:3000"),

View File

@@ -0,0 +1,46 @@
# claudemesh v0.1.0 — Launch Day Runbook
## T-30min: Final Checks
- `dig claudemesh.com` and `dig ic.claudemesh.com` resolve to VPS.
- `curl -I https://claudemesh.com/health` and `https://ic.claudemesh.com/health` return 200.
- Verify Traefik TLS cert (not expiring in 30 days).
- `npm publish --dry-run` on CLI package; confirm version is 0.1.0.
- Tail broker and web logs in Coolify.
- Confirm pg_dump cron loaded (`systemctl list-timers | grep pg_dump`).
- Silence unrelated alerts; pin on-call rotation.
## T-0: Launch
- Fire HN "Show HN: claudemesh" post.
- Cross-post to r/LocalLLaMA, r/ClaudeAI, r/selfhosted.
- Thread owner pins themselves for the first 6h to answer every comment.
- Share on X/Bluesky/LinkedIn.
## First 6h — Watch Window
- Broker `/metrics`: `claudemesh_ws_connections` — alarm >500.
- Web + broker 429 rate: if >2% of traffic, raise limits.
- Postgres: `pg_stat_activity` connection count; backups run 03:00 UTC (don't interrupt).
- Traefik logs: TLS renewal errors, 5xx spikes.
- Signup funnel + mesh-create events every 30 min.
- Broker memory on VPS (`docker stats`): escalate at >80%.
## Common Failures — Responses
- **Broker OOM**: bump container memory in Coolify to 2GB, redeploy. Review connection leaks after.
- **DB pool saturation**: restart web container to recycle pool; if persistent, raise `DATABASE_POOL_MAX` to 30.
- **Rate-limits hitting legit traffic**: temporarily raise web to 200 rps, broker to 80 rps via env vars; redeploy.
- **Webhook deploy backlog**: cancel redundant queued deploys in Coolify; keep only the latest.
- **Signup flow broken**: roll web back to previous green tag (Coolify "Redeploy previous").
- **Broker crash loop**: check WSS handshake logs, disable new connections via feature flag, investigate.
## Who to Page
- **Broker bugs, WSS, protocol** → `claudemesh` peer.
- **Web UI, signup, dashboard** → `claudemesh-2` peer.
- **VPS, Traefik, DNS, Postgres, Coolify** → `ovhcloud-agutmou` peer.
- **DB schema / migrations** → `claudemesh` peer.
- **CLI / npm package** → `claudemesh` peer.
## T+24h: Post-Launch
- Pull metrics: peak connections, signup count, mesh count, 429 rate, p95 latency.
- Review rate-limit hits; adjust ceilings to real traffic shape.
- Triage GitHub issues opened during launch; tag v0.2 candidates.
- Retro with peers: biggest fire, biggest win, one fix for v0.2.
- Schedule v0.2 planning for T+72h.

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

96
marketing/COPY.md Normal file
View File

@@ -0,0 +1,96 @@
# claudemesh.com — Marketing Copy
## Hero
**CLAUDEMESH**
*Every Claude Code session, woven into one mesh.*
Open source. Self-hosted. Built for teams that already live inside Claude Code.
[ Get started ] [ Star on GitHub ]
---
## One-liner variants (for social, OG, README)
- Turn every teammate's Claude Code into a shared workspace.
- A mesh network for Claude Code — one session per dev, all talking.
- Stop DMing context. Let the agents coordinate.
- Your team's Claude sessions, one lattice.
---
## The problem
Claude Code already lives on every engineer's laptop. It reads the repo, runs commands, edits files, keeps context. Each session is brilliant in isolation — and completely blind to the other five running on your team's machines right now.
So engineers paste context into Slack. They screenshot terminals. They rebuild the same mental model Claude already had on someone else's machine.
The work doubles. The context dies on every restart.
## What claudemesh does
claudemesh is a self-hosted broker that connects Claude Code sessions across machines into one live mesh.
- Every session announces what it is working on.
- Any session can message another — by human name, by repo, by machine.
- Messages route through a local WebSocket broker you run yourself.
- Presence, priority, and status are tracked automatically from each session's activity.
No cloud account. No training on your code. Your mesh, your machines, your rules.
---
## Real scenarios
### Platform team owns twelve services
Infra engineer spins up Claude Code pointed at the Terraform repo. Backend engineer has Claude Code in the service repo. When infra ships a new secret name, Claude on the infra side messages Claude on the backend side: *"SECRET_RENAMED auth-token → auth-token-v2, bump your env loader."* Backend Claude picks up the message next time the engineer goes idle, opens the file, makes the edit, asks the human for approval.
Two engineers, two agents, zero Slack threads.
### Database migration across a monorepo
DBA runs a migration in one Claude session. Seven service-owner Claude sessions subscribe to schema changes. When the migration lands, each service's Claude runs its own typecheck, surfaces the breaks to its human, and proposes the fix — already aware of the new schema, because it got the message.
### Oncall handoff at 3 AM
Incident Claude on the oncall laptop has been debugging a prod bug for forty minutes. The oncall rotates. The next engineer opens Claude Code. Their session pulls the summary, the hypotheses tried, the logs read, the files touched. No standup. No writeup. The investigation continues.
### Security review before a release
Release Claude opens a PR. Security Claude on a different machine subscribes to PR-opened events, runs its checklist against the diff, files findings back to the release session. The release engineer sees one consolidated review instead of chasing approvals.
---
## Why enterprises will care
Teams already pay for Claude Code per seat. claudemesh multiplies what those seats do together.
- **Context survives handoffs.** One agent hands work to the next with full history. No rebuilding.
- **Decisions stay in the tool.** No copy-paste into Slack, Jira, or a meeting that did not need to happen.
- **Work parallelises.** Six agents on six machines can coordinate on the same release without humans playing telephone.
- **Your data stays local.** Self-hosted broker. Messages never leave your network.
- **Audit trail by default.** Every message, every status, every handoff, logged.
claudemesh does not replace the engineer. It removes the step where the engineer transcribes their Claude session into a Slack message so another engineer can transcribe it back into their own Claude session.
---
## Why open source, why now
Anthropic built Claude Code as a per-developer tool. The next unlock is between developers. We think that layer should be open, self-hosted, and owned by the teams that run it — not a SaaS tax on a tool you already pay for.
Built on top of the claude-intercom prototype (2 sessions, one laptop). claudemesh scales it to teams, machines, and offices.
Run the broker. Point your Claude Code at it. Watch the mesh light up.
---
## Calls to action
- **For developers:** `npx claudemesh init` — three commands, running in sixty seconds.
- **For teams:** Self-host the broker on one machine in your network. Everyone else joins.
- **For Anthropic:** This is the agent-to-agent layer the community will build anyway. Let's build it together.
[ github.com/claudemesh/claudemesh ]

View File

@@ -0,0 +1,244 @@
{
"--_theme---button-brand--text": "#faf9f5",
"--_grid---column-count": "12",
"--swatch--clay": "#d97757",
"--radius--x-small": ".25rem",
"--_typography---line-height--1-7": "1.7",
"--nav--icon-thickness": ".0625rem",
"--_typography---font-size--body-large-2": "clamp(1.25*1rem,((1.25 - ((1.4375 - 1.25)/(90 - 20)*20))*1rem + ((1.4375 - 1.25)/(90 - 20))*100vw),1.4375*1rem)",
"--_theme---switch--dot-background-active": "white",
"--radius--large": "1rem",
"--_theme---button-primary--border": "#30302e",
"--_theme---button-brand--background": "#c96442",
"--_typography---letter-spacing--0em": "0em",
"--moat-shadow": "rgba(0, 0, 0, 0.1)",
"--_typography---line-height--1-5": "1.5",
"--moat-bg-tertiary": "#f3f4f6",
"--moat-text-primary": "#1f2937",
"--_theme---button-primary--text-hover": "#faf9f5",
"--_text-style---font-weight": "400",
"--_theme---button-primary--background-hover": "#1f1e1d",
"--moat-warning-light": "rgba(245, 158, 11, 0.1)",
"--_state---false": "0",
"--radius--x-large": "clamp(1*1rem,((1 - ((1.5 - 1)/(90 - 20)*20))*1rem + ((1.5 - 1)/(90 - 20))*100vw),1.5*1rem)",
"--swatch--gray-050": "#faf9f5",
"--_typography---line-height--1-2": "1.2",
"--moat-error": "#dc2626",
"--_typography---text-transform--none": "none",
"--_theme---button-tertiary--border-hover": "#d1cfc5",
"--_theme---selection--background": "color-mix(in srgb,#d97757/**/50%,transparent)",
"--grid-breakout": "[full-start] minmax(0, 1fr) [content-start] repeat(12, minmax(0, calc((min(calc(90*1rem),100% - clamp(2*1rem,((2 - ((4 - 2)/(90 - 20)*20))*1rem + ((4 - 2)/(90 - 20))*100vw),4*1rem)*2) - (2rem*(12 - 1)))/12))) [content-end] minmax(0, 1fr) [full-end]",
"--_typography---font--text-trim-top": ".39em",
"--_button-style---background-hover": "#c96442",
"--swatch--gray-000": "white",
"--swatch--gray-350": "#c2c0b6",
"--_alignment---direction": "start",
"--_typography---font-size--h1": "clamp(2.125*1rem,((2.125 - ((3.25 - 2.125)/(90 - 20)*20))*1rem + ((3.25 - 2.125)/(90 - 20))*100vw),3.25*1rem)",
"--_button-style---text": "#faf9f5",
"--_trigger---off": "0",
"--swatch--gray-150": "#f0eee6",
"--_text-style---trim-bottom": ".38em",
"--swatch--fig": "#c46686",
"--_typography---font-size--h3": "clamp(1.75*1rem,((1.75 - ((2.25 - 1.75)/(90 - 20)*20))*1rem + ((2.25 - 1.75)/(90 - 20))*100vw),2.25*1rem)",
"--swatch--gray-850": "#1f1e1d",
"--_typography---letter-spacing--0-05em": ".05em",
"--_theme---button-brand--icon-hover": "#faf9f5",
"--max-width--main": "calc(90*1rem)",
"--_theme---foreground-tertiary": "#5e5d59",
"--focus--width": ".125rem",
"--swatch--oat": "#e3dacc",
"--_text-style---font-size": "clamp(1.1875*1rem,((1.1875 - ((1.25 - 1.1875)/(90 - 20)*20))*1rem + ((1.25 - 1.1875)/(90 - 20))*100vw),1.25*1rem)",
"--swatch--gray-450": "#9c9a92",
"--_typography---font--mono-text-trim-bottom": ".37em",
"--_theme---button-tertiary--background": "#faf9f5",
"--_theme---switch--border-active": "transparent",
"--_theme---button-brand--background-hover": "#c96442",
"--_text-style---font-weight<deleted|variable-4b164c1c-8d1a-d4a3-2cae-137e7ca87326>": "400",
"--swiper-theme-color": "#007aff",
"--_button-style---text-hover": "#faf9f5",
"--swatch--gray-800": "#262624",
"--_button-style---spacer-width-hover": ".0625rem",
"--_spacing---space--2rem": "clamp(1.75*1rem,((1.75 - ((2 - 1.75)/(90 - 20)*20))*1rem + ((2 - 1.75)/(90 - 20))*100vw),2*1rem)",
"--_theme---foreground-secondary": "#30302e",
"--moat-accent": "#3b82f6",
"--_typography---font--logographic-family": "\"Noto Sans\",Arial,sans-serif",
"--_theme---button-secondary--icon": "#4d4c48",
"--_text-style---trim-top": ".39em",
"--_theme---button-secondary--border-hover": "#e8e6dc",
"--_theme---button-primary--border-hover": "#30302e",
"--swatch--cactus": "#bcd1ca",
"--_theme---switch--background": "#f0eee6",
"--_theme---button-primary--text": "#faf9f5",
"--moat-warning-border": "rgba(245, 158, 11, 0.2)",
"--site--gutter<deleted|variable-19914bb2-08fa-8b60-b710-6beb999f4c42>": "2rem",
"--_typography---font--primary-bold": "700",
"--_trigger---on": "1",
"--_typography---font-size--h2": "clamp(1.875*1rem,((1.875 - ((2.75 - 1.875)/(90 - 20)*20))*1rem + ((2.75 - 1.875)/(90 - 20))*100vw),2.75*1rem)",
"--radius--main": ".75rem",
"--_typography---line-height--1-1": "1.1",
"--_theme---switch--background-active": "#2c84db",
"--_column-count---value": "1",
"--_spacing---section-space--main": "clamp(6*1rem,((6 - ((8 - 6)/(90 - 20)*20))*1rem + ((8 - 6)/(90 - 20))*100vw),8*1rem)",
"--_text-style---line-height": "1.6",
"--radius--xx-large": "clamp(1*1rem,((1 - ((2 - 1)/(90 - 20)*20))*1rem + ((2 - 1)/(90 - 20))*100vw),2*1rem)",
"--grid-breakout-single": "[full-start] minmax(0, 1fr) [content-start] minmax(0, calc(100% - clamp(2*1rem,((2 - ((4 - 2)/(90 - 20)*20))*1rem + ((4 - 2)/(90 - 20))*100vw),4*1rem) * 2)) [content-end] minmax(0, 1fr) [full-end]",
"--_theme---border-secondary": "#d1cfc5",
"--moat-scrollbar-track": "transparent",
"--max-width--small": "60rem",
"--_theme---button-secondary--border": "#d1cfc5",
"--_typography---font-size--h5": "clamp(1.25*1rem,((1.25 - ((1.5625 - 1.25)/(90 - 20)*20))*1rem + ((1.5625 - 1.25)/(90 - 20))*100vw),1.5625*1rem)",
"--moat-accent-light": "rgba(59, 130, 246, 0.1)",
"--_theme---button-primary--icon": "#faf9f5",
"--moat-border": "#e5e7eb",
"--_theme---switch--dot-border": "#b0aea5",
"--swatch--gray-1000": "#000",
"--swatch--gray-250": "#dedcd1",
"--swatch--gray-400": "#b0aea5",
"--_theme---button-primary--icon-hover": "#faf9f5",
"--swatch--coral": "#ebcece",
"--_button-style---border-hover": "#c96442",
"--moat-error-border": "rgba(239, 68, 68, 0.2)",
"--_typography---font--text-trim-bottom": ".38em",
"--_spacing---space--0-75rem": "clamp(.75*1rem,((.75 - ((.75 - .75)/(90 - 20)*20))*1rem + ((.75 - .75)/(90 - 20))*100vw),.75*1rem)",
"--nav--dropdown-duration": "300ms",
"--_theme---button-tertiary--icon": "#5e5d59",
"--_typography---text-transform--uppercase": "uppercase",
"--swatch--heather": "#cbcadb",
"--_text-style---text-wrap": "pretty",
"--_theme---button-brand--text-hover": "#faf9f5",
"--nav--dropdown-delay": "0ms",
"--moat-notification-text": "#ffffff",
"--_typography---font-size--body-3": "clamp(.9375*1rem,((.9375 - ((.9375 - .9375)/(90 - 20)*20))*1rem + ((.9375 - .9375)/(90 - 20))*100vw),.9375*1rem)",
"--_theme---button-secondary--background": "#e8e6dc",
"--_theme---button-secondary--background-hover": "white",
"--moat-success": "#059669",
"--max-width--medium": "74.5rem",
"--swatch--gray-600": "#5e5d59",
"--moat-success-border": "rgba(34, 197, 94, 0.2)",
"--_spacing---space--1-5rem": "clamp(1.5*1rem,((1.5 - ((1.5 - 1.5)/(90 - 20)*20))*1rem + ((1.5 - 1.5)/(90 - 20))*100vw),1.5*1rem)",
"--swatch--clay-interactive": "#c96442",
"--_theme---selection--text": "#141413",
"--_typography---font-size--micro": "clamp(.625*1rem,((.625 - ((.625 - .625)/(90 - 20)*20))*1rem + ((.625 - .625)/(90 - 20))*100vw),.625*1rem)",
"--_typography---font--primary-family": "\"Anthropic Sans\",Arial,sans-serif",
"--_typography---line-height--1-3": "1.3",
"--_spacing---section-space--small": "clamp(4*1rem,((4 - ((6 - 4)/(90 - 20)*20))*1rem + ((6 - 4)/(90 - 20))*100vw),6*1rem)",
"--_theme---foreground-primary": "#141413",
"--swatch--gray-100": "#f5f4ed",
"--_typography---font--mono-family": "\"Anthropic Mono\",Arial,sans-serif",
"--_theme---pictogram-accent": "#e3dacc",
"--_typography---font-size--body-large-1": "clamp(1.375*1rem,((1.375 - ((1.5 - 1.375)/(90 - 20)*20))*1rem + ((1.5 - 1.375)/(90 - 20))*100vw),1.5*1rem)",
"--moat-bg-primary": "#ffffff",
"--swatch--gray-950": "#141413",
"--_button-style---spacer-width": "0rem",
"--_spacing---section-space--large": "clamp(8*1rem,((8 - ((12.5 - 8)/(90 - 20)*20))*1rem + ((12.5 - 8)/(90 - 20))*100vw),12.5*1rem)",
"--swatch--gray-550": "#73726c",
"--moat-border-light": "#f3f4f6",
"--_button-style---border-width": ".0625rem",
"--_text-style---logographic-family": "\"Noto Sans\",Arial,sans-serif",
"--focus--offset-inner": "-.125rem",
"--swatch--gray-500": "#87867f",
"--_theme---button-secondary--text-hover": "#141413",
"--nav--hamburger-gap": "clamp(.25*1rem,((.25 - ((.25 - .25)/(90 - 20)*20))*1rem + ((.25 - .25)/(90 - 20))*100vw),.25*1rem)",
"--_theme---background-primary": "#faf9f5",
"--site--viewport-min": "20",
"--swatch--gray-700": "#3d3d3a",
"--moat-text-tertiary": "#6b7280",
"--_button-style---border-width-hover": "calc(.0625rem*2)",
"--swatch--gray-200": "#e8e6dc",
"--mcp-pointer-border-color": "#7abaffc0",
"--_grid---column-width": "calc((min(calc(90*1rem),100% - clamp(2*1rem,((2 - ((4 - 2)/(90 - 20)*20))*1rem + ((4 - 2)/(90 - 20))*100vw),4*1rem)*2) - (2rem*(12 - 1)))/12)",
"--swatch--gray-750": "#30302e",
"--site--margin": "clamp(2*1rem,((2 - ((4 - 2)/(90 - 20)*20))*1rem + ((4 - 2)/(90 - 20))*100vw),4*1rem)",
"--_spacing---space--3rem": "clamp(2.5*1rem,((2.5 - ((3 - 2.5)/(90 - 20)*20))*1rem + ((3 - 2.5)/(90 - 20))*100vw),3*1rem)",
"--_typography---font-size--body-1": "clamp(1.1875*1rem,((1.1875 - ((1.25 - 1.1875)/(90 - 20)*20))*1rem + ((1.25 - 1.1875)/(90 - 20))*100vw),1.25*1rem)",
"--_theme---border-tertiary": "#e8e6dc",
"--_theme---button-primary--background": "#141413",
"--_text-style---letter-spacing": "0em",
"--nav--dropdown-open-duration": "600ms",
"--_theme---button-tertiary--text": "#5e5d59",
"--swatch--gray-650": "#4d4c48",
"--swatch--brand-600<deleted|variable-f4848f9a-e1c5-5c7a-9707-4fe0d1542434>": "color-mix(in srgb,#d97757,black 20%)",
"--moat-shadow-lg": "rgba(0, 0, 0, 0.15)",
"--_theme---button-brand--border-hover": "#c96442",
"--_typography---font--primary-semibold": "600",
"--_text-style---text-transform": "none",
"--swatch--transparent": "transparent",
"--_spacing---space--0-5rem": "clamp(.5*1rem,((.5 - ((.5 - .5)/(90 - 20)*20))*1rem + ((.5 - .5)/(90 - 20))*100vw),.5*1rem)",
"--_state---true": "1",
"--_typography---font-size--body-2": "clamp(1.0625*1rem,((1.0625 - ((1.0625 - 1.0625)/(90 - 20)*20))*1rem + ((1.0625 - 1.0625)/(90 - 20))*100vw),1.0625*1rem)",
"--_button-style---border": "#c96442",
"--ease-expo-out": "cubic-bezier(0.16, 1, 0.3, 1)",
"--_typography---font-size--h4": "clamp(1.4375*1rem,((1.4375 - ((2 - 1.4375)/(90 - 20)*20))*1rem + ((2 - 1.4375)/(90 - 20))*100vw),2*1rem)",
"--_text-style---font-family": "\"Anthropic Sans\",Arial,sans-serif",
"--_theme---button-brand--icon": "#faf9f5",
"--_button-style---icon": "#faf9f5",
"--site--column-count<deleted|variable-85d23ac9-df16-3529-599c-7c03076ebe38>": "12",
"--_text-style---margin-bottom": "clamp(1.5*1rem,((1.5 - ((1.5 - 1.5)/(90 - 20)*20))*1rem + ((1.5 - 1.5)/(90 - 20))*100vw),1.5*1rem)",
"--_theme---button-brand--border": "#c96442",
"--_theme---background-tertiary": "#f0eee6",
"--_spacing---space--1rem": "clamp(1*1rem,((1 - ((1 - 1)/(90 - 20)*20))*1rem + ((1 - 1)/(90 - 20))*100vw),1*1rem)",
"--moat-scrollbar-thumb": "#d1d5db",
"--_theme---text-accent": "#d97757",
"--radius--small": ".5rem",
"--swatch--gray-900": "#1a1918",
"--_theme---error-text": "#b53333",
"--border-width--main": ".0625rem",
"--_theme---border-primary": "#b0aea5",
"--moat-success-light": "rgba(34, 197, 94, 0.1)",
"--moat-warning": "#d97706",
"--swatch--olive": "#788c5d",
"--_typography---font--secondary-family": "\"Anthropic Serif\",Georgia,sans-serif",
"--_button-style---icon-hover": "#faf9f5",
"--focus--offset-outer": ".25rem",
"--_theme---switch--dot-border-active": "transparent",
"--_grid---gutter": "2rem",
"--_button-style---background": "#c96442",
"--_theme---heading-accent<deleted|variable-25bd0d95-1867-08bf-9f2f-eabc649f971e>": "color-mix(in srgb,#d97757,black 20%)",
"--_typography---font--mono-text-trim-top": ".4em",
"--_typography---font--primary-medium<deleted|variable-bf70a7c1-809a-4d78-48d8-6a700e801b65>": "500",
"--_typography---font--primary-light": "300",
"--_theme---button-tertiary--background-hover": "#faf9f5",
"--_theme---button-tertiary--border": "#d1cfc5",
"--site--viewport-max": "90",
"--swatch--sky": "#6a9bcc",
"--moat-text-muted": "#9ca3af",
"--moat-bg-secondary": "#f9fafb",
"--swiper-navigation-size": "44px",
"--_theme---heroes-accent": "#d97757",
"--_typography---font--primary-medium": "500",
"--_gap---size": "2rem",
"--_theme---button-secondary--icon-hover": "#30302e",
"--_typography---font-size--h6": "clamp(1*1rem,((1 - ((1.1875 - 1)/(90 - 20)*20))*1rem + ((1.1875 - 1)/(90 - 20))*100vw),1.1875*1rem)",
"--_typography---font--primary-regular<deleted|variable-e2e11636-2778-b266-3d73-a7bb3f1f201f>": "400",
"--swatch--gray-300": "#d1cfc5",
"--_typography---line-height--1-6": "1.6",
"--_theme---white": "white",
"--_theme---button-secondary--text": "#4d4c48",
"--_spacing---space--2-5rem": "clamp(2*1rem,((2 - ((2.5 - 2)/(90 - 20)*20))*1rem + ((2.5 - 2)/(90 - 20))*100vw),2.5*1rem)",
"--moat-notification-bg": "#1f2937",
"--nav--hamburger-rotate": "45",
"--max-width--full": "100%",
"--_theme---background-secondary": "#f5f4ed",
"--moat-accent-border": "rgba(59, 130, 246, 0.2)",
"--_typography---font--primary-regular": "400",
"--nav--hamburger-thickness": ".0625rem",
"--_typography---text-transform--capitalize": "capitalize",
"--_typography---line-height--1": "1",
"--_spacing---space--4rem": "clamp(3.25*1rem,((3.25 - ((4 - 3.25)/(90 - 20)*20))*1rem + ((4 - 3.25)/(90 - 20))*100vw),4*1rem)",
"--_spacing---space--0-25rem": "clamp(.25*1rem,((.25 - ((.25 - .25)/(90 - 20)*20))*1rem + ((.25 - .25)/(90 - 20))*100vw),.25*1rem)",
"--moat-scrollbar-thumb-hover": "#9ca3af",
"--_typography---font-size--display-2": "clamp(2.25*1rem,((2.25 - ((4 - 2.25)/(90 - 20)*20))*1rem + ((4 - 2.25)/(90 - 20))*100vw),4*1rem)",
"--moat-error-light": "rgba(239, 68, 68, 0.1)",
"--_typography---font-size--caption": "clamp(.75*1rem,((.75 - ((.75 - .75)/(90 - 20)*20))*1rem + ((.75 - .75)/(90 - 20))*100vw),.75*1rem)",
"--_spacing---section-space--none": "0px",
"--_theme---switch--dot-background": "white",
"--_spacing---section-space--page-top": "clamp(12*1rem,((12 - ((15 - 12)/(90 - 20)*20))*1rem + ((15 - 12)/(90 - 20))*100vw),15*1rem)",
"--_typography---text-transform--lowercase": "lowercase",
"--_theme---switch--border": "#d1cfc5",
"--_typography---font-size--display-1": "clamp(2.625*1rem,((2.625 - ((4.5 - 2.625)/(90 - 20)*20))*1rem + ((4.5 - 2.625)/(90 - 20))*100vw),4.5*1rem)",
"--_typography---letter-spacing--0-01em": ".01em",
"--_theme---button-tertiary--icon-hover": "#141413",
"--moat-text-secondary": "#4b5563",
"--_theme---button-tertiary--text-hover": "#141413",
"--_text-style---margin-top": "clamp(1*1rem,((1 - ((1 - 1)/(90 - 20)*20))*1rem + ((1 - 1)/(90 - 20))*100vw),1*1rem)"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

View File

@@ -0,0 +1,87 @@
# claudemesh — Design System
Extracted from `claude.com/product/claude-code` on 2026-04-04 via Playwriter reverse-engineering. 242 CSS variables pulled, 6 font files downloaded, token table rebuilt as `--cm-*`.
Not "inspired by". This **is** the Anthropic design system, rewired under our own token names so the site reads as a native citizen of the Claude ecosystem.
## Fonts (self-hosted, woff2)
| Family | Weights | File |
|---|---|---|
| Anthropic Sans | 300800 | `/fonts/AnthropicSans-Roman.woff2` + Italic |
| Anthropic Serif | 300800 | `/fonts/AnthropicSerif-Roman.woff2` + Italic |
| Anthropic Mono | 300800 | `/fonts/AnthropicMono-Roman.woff2` + Italic |
**Usage**
- **Serif** → display headlines, scenario titles, long-form body prose (the Anthropic voice)
- **Sans** → UI: buttons, nav, pillar labels
- **Mono** → code, terminal, metadata tags, section labels
## Color palette (swatch names from claude.com)
| Token | Hex | Role |
|---|---|---|
| `--cm-clay` | `#d97757` | Brand primary (Claude orange) |
| `--cm-clay-hover` | `#c96442` | Brand hover |
| `--cm-fig` | `#c46686` | Accent pink |
| `--cm-oat` | `#e3dacc` | Warm cream |
| `--cm-cactus` | `#bcd1ca` | Sage |
| `--cm-gray-050` | `#faf9f5` | Foreground (on dark) |
| `--cm-gray-150` | `#f0eee6` | Surface (light mode) |
| `--cm-gray-350` | `#c2c0b6` | Text secondary |
| `--cm-gray-450` | `#9c9a92` | Text tertiary |
| `--cm-gray-800` | `#262624` | Surface hover (dark) |
| `--cm-gray-850` | `#1f1e1d` | Elevated surface (dark) |
| `--cm-gray-900` | `#141413` | Page background (dark) |
## Type scale (fluid clamp, from Anthropic's own scale)
| Token | Min → Max | Use |
|---|---|---|
| `--cm-text-h1` | 2.125rem → 3.25rem | Page titles |
| `--cm-text-h2` | 1.875rem → 2.75rem | Section headers |
| `--cm-text-h3` | 1.75rem → 2.25rem | Card titles |
| `--cm-text-body-lg` | 1.1875rem → 1.25rem | Lede paragraph |
- Line-heights: 1.2 (display), 1.5 (UI), 1.7 (body prose)
- Letter-spacing: 0 default, 0.05em on labels, 0.22em on section markers
## Spacing & layout
- Gutter: `2rem`
- Max width: `90rem`
- Grid: 12-col with gutters
- Section padding: `py-32 px-8 md:px-16`
## Radii
- `--cm-radius-xs`: 0.25rem (buttons, inputs, tags)
- `--cm-radius-md`: 0.5rem
- `--cm-radius-lg`: 1rem (hero cards, CTA box)
## Motion
- `--cm-dur`: 300ms
- `--cm-ease`: `cubic-bezier(0.22, 0.61, 0.36, 1)`
- All transitions color + transform only, no layout shifts
## Signature touches (claudemesh's own voice on top)
- Italic serif phrases in clay for emphasis — Anthropic uses this too
- Mono section markers prefixed with `—` (e.g. `— real scenarios`)
- Terminal-style tag chips in mono
- `$ npx claudemesh init` command blocks with blinking clay cursor
- Hero backdrop: generated mesh image at 50% opacity with gradient fade to bg
## Files
- `apps/web/src/assets/styles/globals.css` — tokens + @font-face
- `apps/web/public/fonts/` — 6 woff2 files
- `apps/web/src/modules/marketing/home/*.tsx` — sections using tokens
- `marketing/anthropic-tokens.json` — full 242-var dump (reference)
- `marketing/assets/fonts/` — master copies of font files
- `marketing/assets/anthropic-refs/` — screenshots for visual reference
## Legal note
This uses Anthropic's proprietary fonts and exact color tokens. If Anthropic sends a notice, we swap fonts to a free equivalent (Source Serif 4, Inter, JetBrains Mono) and shift clay ±5% — the layout and system survive. Until then: full native ecosystem look.

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB