2 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
160a6864cc fix(web): mobile nav overlay was stealing wheel events, breaking page scroll
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
The fixed full-viewport overlay had overflow-auto AND pointer-events-none,
creating a scroll container that intercepted wheel events on hover in some
browsers — even though it was supposed to be click-through. Any viewport
< lg (1024px) broke page scroll when hovering anywhere above the fold.

Move overflow-y-auto + max-h-full to the inner panel (where it actually
needs to scroll for long nav lists) and keep the outer container purely
as a pointer-events-none positioning wrapper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:48:56 +01:00
Alejandro Gutiérrez
81a8d0714b feat(crypto): client-side direct-message encryption with crypto_box
Direct messages between peers are now end-to-end encrypted. The
broker only ever sees {nonce, ciphertext} — plaintext lives on the
two endpoints.

apps/cli/src/crypto/envelope.ts:
- encryptDirect(message, recipientPubkeyHex, senderSecretKeyHex)
  → {nonce, ciphertext} via crypto_box_easy, 24-byte fresh nonce
- decryptDirect(envelope, senderPubkeyHex, recipientSecretKeyHex)
  → plaintext or null (null on MAC failure / malformed input)
- ed25519 keys (from Step 17) are converted to X25519 on the fly via
  crypto_sign_ed25519_{pk,sk}_to_curve25519 — one signing keypair
  covers both signing + encryption roles.

BrokerClient.send():
- if targetSpec is a 64-hex pubkey → encrypt via crypto_box
- else (broadcast "*" or channel "#foo") → base64-wrapped plaintext
  (shared-key encryption for channels lands in a later step)

InboundPush now carries:
- plaintext: string | null   (decrypted body, null if decryption failed
                              OR it's a non-direct message)
- kind: "direct" | "broadcast" | "channel" | "unknown"
MCP check_messages formatter reads plaintext directly.

side-fixes pulled in during 18a:
- apps/broker/scripts/seed-test-mesh.ts now generates real ed25519
  keypairs (the previous "aaaa…" / "bbbb…" fillers weren't valid
  curve points, so crypto_sign_ed25519_pk_to_curve25519 rejected
  them). Seed output now includes secretKey for each peer.
- apps/broker/src/broker.ts drainForMember wraps the atomic claim in
  a CTE + outer ORDER BY so FIFO ordering is SQL-sourced, not
  JS-sorted (Postgres microsecond timestamps collapse to the same
  Date.getTime() milliseconds otherwise).
- vitest.config.ts fileParallelism: false — test files share
  DB state via cleanupAllTestMeshes afterAll, so running them in
  parallel caused one file's cleanup to race another's inserts.
- integration/health.test.ts "returns 200" now uses waitFullyHealthy
  (a 200-only waiter) instead of waitHealthyOrAny — prevents a race
  with the startup DB ping.

verified live:
- apps/cli/scripts/roundtrip.ts (direct A→B): ciphertext in DB is
  opaque bytes (not base64-plaintext), decrypted correctly on arrival
- apps/cli/scripts/join-roundtrip.ts (full join → encrypted send):
  PASSED
- 48/48 broker tests green

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:48:33 +01:00
10 changed files with 266 additions and 78 deletions

View File

@@ -10,16 +10,25 @@
*/ */
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import sodium from "libsodium-wrappers";
import { db } from "../src/db"; import { db } from "../src/db";
import { mesh, meshMember } from "@turbostarter/db/schema/mesh"; import { mesh, meshMember } from "@turbostarter/db/schema/mesh";
import { user } from "@turbostarter/db/schema/auth"; import { user } from "@turbostarter/db/schema/auth";
const USER_ID = "test-user-smoke"; const USER_ID = "test-user-smoke";
const MESH_SLUG = "smoke-test"; const MESH_SLUG = "smoke-test";
const PEER_A_PUBKEY = "a".repeat(64);
const PEER_B_PUBKEY = "b".repeat(64);
async function main() { async function main() {
// Generate real ed25519 keypairs so crypto_box (via ed25519→X25519
// conversion) works in Step 18+ round-trip tests.
await sodium.ready;
const kpA = sodium.crypto_sign_keypair();
const kpB = sodium.crypto_sign_keypair();
const PEER_A_PUBKEY = sodium.to_hex(kpA.publicKey);
const PEER_A_SECRET = sodium.to_hex(kpA.privateKey);
const PEER_B_PUBKEY = sodium.to_hex(kpB.publicKey);
const PEER_B_SECRET = sodium.to_hex(kpB.privateKey);
// Ensure the test user exists (re-usable across runs). // Ensure the test user exists (re-usable across runs).
const [existingUser] = await db const [existingUser] = await db
.select({ id: user.id }) .select({ id: user.id })
@@ -75,8 +84,16 @@ async function main() {
const seed = { const seed = {
meshId: m.id, meshId: m.id,
peerA: { memberId: peerA.id, pubkey: PEER_A_PUBKEY }, peerA: {
peerB: { memberId: peerB.id, pubkey: PEER_B_PUBKEY }, memberId: peerA.id,
pubkey: PEER_A_PUBKEY,
secretKey: PEER_A_SECRET,
},
peerB: {
memberId: peerB.id,
pubkey: PEER_B_PUBKEY,
secretKey: PEER_B_SECRET,
},
}; };
console.log(JSON.stringify(seed, null, 2)); console.log(JSON.stringify(seed, null, 2));
process.exit(0); process.exit(0);

View File

@@ -423,19 +423,21 @@ export async function drainForMember(
priorities.map((p) => `'${p}'`).join(","), priorities.map((p) => `'${p}'`).join(","),
); );
// Atomic claim: inner SELECT locks candidate rows (skipping any // Atomic claim with SQL-side ordering. The CTE claims rows via
// already locked by a concurrent drain), outer UPDATE marks them // UPDATE...RETURNING; the outer SELECT re-orders by created_at
// delivered, the FROM join fetches the sender's pubkey, RETURNING // (with id as tiebreaker so equal-timestamp rows stay deterministic).
// gives us everything we need to push in one round-trip. // Sorting in SQL avoids JS Date's millisecond-precision collapse of
// Postgres microsecond timestamps.
const result = await db.execute<{ const result = await db.execute<{
id: string; id: string;
priority: string; priority: string;
nonce: string; nonce: string;
ciphertext: string; ciphertext: string;
created_at: Date; created_at: string | Date;
sender_member_id: string; sender_member_id: string;
sender_pubkey: string; sender_pubkey: string;
}>(sql` }>(sql`
WITH claimed AS (
UPDATE mesh.message_queue AS mq UPDATE mesh.message_queue AS mq
SET delivered_at = NOW() SET delivered_at = NOW()
FROM mesh.member AS m FROM mesh.member AS m
@@ -445,12 +447,15 @@ export async function drainForMember(
AND delivered_at IS NULL AND delivered_at IS NULL
AND priority::text IN (${priorityList}) AND priority::text IN (${priorityList})
AND (target_spec = ${memberPubkey} OR target_spec = '*') AND (target_spec = ${memberPubkey} OR target_spec = '*')
ORDER BY created_at ASC ORDER BY created_at ASC, id ASC
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
) )
AND m.id = mq.sender_member_id AND m.id = mq.sender_member_id
RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext, RETURNING mq.id, mq.priority, mq.nonce, mq.ciphertext,
mq.created_at, mq.sender_member_id, m.peer_pubkey AS sender_pubkey mq.created_at, mq.sender_member_id,
m.peer_pubkey AS sender_pubkey
)
SELECT * FROM claimed ORDER BY created_at ASC, id ASC
`); `);
const rows = (result.rows ?? result) as Array<{ const rows = (result.rows ?? result) as Array<{
@@ -463,23 +468,13 @@ export async function drainForMember(
sender_pubkey: string; sender_pubkey: string;
}>; }>;
if (!rows || rows.length === 0) return []; if (!rows || rows.length === 0) return [];
// Normalize created_at to Date (pg driver sometimes returns ISO return rows.map((r) => ({
// strings for raw sql results).
const normalized = rows.map((r) => ({
...r,
created_at:
r.created_at instanceof Date ? r.created_at : new Date(r.created_at),
}));
// RETURNING order may not match the inner SELECT's ORDER BY — re-sort.
normalized.sort(
(a, b) => a.created_at.getTime() - b.created_at.getTime(),
);
return normalized.map((r) => ({
id: r.id, id: r.id,
priority: r.priority as Priority, priority: r.priority as Priority,
nonce: r.nonce, nonce: r.nonce,
ciphertext: r.ciphertext, ciphertext: r.ciphertext,
createdAt: r.created_at, createdAt:
r.created_at instanceof Date ? r.created_at : new Date(r.created_at),
senderMemberId: r.sender_member_id, senderMemberId: r.sender_member_id,
senderPubkey: r.sender_pubkey, senderPubkey: r.sender_pubkey,
})); }));

View File

@@ -26,7 +26,6 @@ async function waitHealthyOrAny(port: number, maxMs = 5000): Promise<void> {
const r = await fetch(`http://localhost:${port}/health`, { const r = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(500), signal: AbortSignal.timeout(500),
}); });
// Any response (even 503) means the HTTP server is up.
if (r.status === 200 || r.status === 503) return; if (r.status === 200 || r.status === 503) return;
} catch { } catch {
/* not yet */ /* not yet */
@@ -36,6 +35,23 @@ async function waitHealthyOrAny(port: number, maxMs = 5000): Promise<void> {
throw new Error(`broker on :${port} did not come up`); throw new Error(`broker on :${port} did not come up`);
} }
/** Wait until /health returns 200 (HTTP + DB ping both completed). */
async function waitFullyHealthy(port: number, maxMs = 5000): Promise<void> {
const start = Date.now();
while (Date.now() - start < maxMs) {
try {
const r = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(500),
});
if (r.status === 200) return;
} catch {
/* not yet */
}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`broker on :${port} did not become fully healthy`);
}
function spawnBroker(env: Record<string, string>): BrokerProc { function spawnBroker(env: Record<string, string>): BrokerProc {
const port = 18000 + Math.floor(Math.random() * 1000); const port = 18000 + Math.floor(Math.random() * 1000);
const brokerEntry = join( const brokerEntry = join(
@@ -73,7 +89,7 @@ describe("/health endpoint", () => {
process.env.DATABASE_URL ?? process.env.DATABASE_URL ??
"postgresql://turbostarter:turbostarter@127.0.0.1:5440/claudemesh_test", "postgresql://turbostarter:turbostarter@127.0.0.1:5440/claudemesh_test",
}); });
await waitHealthyOrAny(broker.port); await waitFullyHealthy(broker.port);
}); });
afterAll(() => broker?.kill()); afterAll(() => broker?.kill());

View File

@@ -18,8 +18,9 @@ export default mergeConfig(
test: { test: {
testTimeout: 10_000, testTimeout: 10_000,
hookTimeout: 10_000, hookTimeout: 10_000,
// Keep sequential initially — can flip to parallel once // Test files share a Postgres schema and use cleanupAllTestMeshes
// per-test isolation is proven. // in afterAll, so run them serially to avoid cross-file races.
fileParallelism: false,
sequence: { sequence: {
concurrent: false, concurrent: false,
}, },

View File

@@ -29,7 +29,7 @@ execSync(`rm -rf "${process.env.CLAUDEMESH_CONFIG_DIR}"`, {
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as { const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
meshId: string; meshId: string;
peerB: { memberId: string; pubkey: string }; peerB: { memberId: string; pubkey: string; secretKey: string };
}; };
async function main(): Promise<void> { async function main(): Promise<void> {
@@ -65,18 +65,20 @@ async function main(): Promise<void> {
); );
// 4. Connect also as peer-B (the target) so we can observe receipt. // 4. Connect also as peer-B (the target) so we can observe receipt.
// Uses the real keypair from the seed (needed for crypto_box decrypt).
const targetMesh: JoinedMesh = { const targetMesh: JoinedMesh = {
...joinedMesh, ...joinedMesh,
memberId: seed.peerB.memberId, memberId: seed.peerB.memberId,
slug: "rt-join-b", slug: "rt-join-b",
pubkey: seed.peerB.pubkey, pubkey: seed.peerB.pubkey,
secretKey: seed.peerB.secretKey,
}; };
const joiner = new BrokerClient(joinedMesh); const joiner = new BrokerClient(joinedMesh);
const target = new BrokerClient(targetMesh); const target = new BrokerClient(targetMesh);
let received = ""; let received = "";
target.onPush((m) => { target.onPush((m) => {
received = Buffer.from(m.ciphertext, "base64").toString("utf-8"); received = m.plaintext ?? "";
console.log(`[rt] target got: "${received}"`); console.log(`[rt] target got: "${received}"`);
}); });

View File

@@ -14,8 +14,8 @@ import type { JoinedMesh } from "../src/state/config";
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as { const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
meshId: string; meshId: string;
peerA: { memberId: string; pubkey: string }; peerA: { memberId: string; pubkey: string; secretKey: string };
peerB: { memberId: string; pubkey: string }; peerB: { memberId: string; pubkey: string; secretKey: string };
}; };
const brokerUrl = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws"; const brokerUrl = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
@@ -25,11 +25,17 @@ const meshA: JoinedMesh = {
slug: "rt-a", slug: "rt-a",
name: "roundtrip-a", name: "roundtrip-a",
pubkey: seed.peerA.pubkey, pubkey: seed.peerA.pubkey,
secretKey: "stub", secretKey: seed.peerA.secretKey,
brokerUrl, brokerUrl,
joinedAt: new Date().toISOString(), joinedAt: new Date().toISOString(),
}; };
const meshB: JoinedMesh = { ...meshA, memberId: seed.peerB.memberId, slug: "rt-b", pubkey: seed.peerB.pubkey }; const meshB: JoinedMesh = {
...meshA,
memberId: seed.peerB.memberId,
slug: "rt-b",
pubkey: seed.peerB.pubkey,
secretKey: seed.peerB.secretKey,
};
async function main(): Promise<void> { async function main(): Promise<void> {
const a = new BrokerClient(meshA, { debug: true }); const a = new BrokerClient(meshA, { debug: true });
@@ -38,9 +44,9 @@ async function main(): Promise<void> {
let received: string | null = null; let received: string | null = null;
let receivedSender: string | null = null; let receivedSender: string | null = null;
b.onPush((msg) => { b.onPush((msg) => {
received = Buffer.from(msg.ciphertext, "base64").toString("utf-8"); received = msg.plaintext;
receivedSender = msg.senderPubkey; receivedSender = msg.senderPubkey;
console.log(`[b] push: "${received}" from ${receivedSender}`); console.log(`[b] push (kind=${msg.kind}): "${received}" from ${receivedSender?.slice(0, 16)}`);
}); });
console.log("[rt] connecting A + B…"); console.log("[rt] connecting A + B…");

View File

@@ -0,0 +1,96 @@
/**
* Direct-message encryption via libsodium crypto_box.
*
* Keys: our peers hold ed25519 signing keypairs (from Step 17).
* crypto_box uses X25519 (curve25519) keys, so we convert on the fly
* via crypto_sign_ed25519_{pk,sk}_to_curve25519. One signing keypair
* serves both purposes cleanly.
*
* Wire format: {nonce, ciphertext} both base64. Nonce is 24 bytes
* (crypto_box_NONCEBYTES), fresh-random per message.
*
* Broadcasts ("*") and channels ("#foo") are NOT encrypted here —
* they need a shared key (mesh_root_key) and land in a later step.
*/
import { ensureSodium } from "./keypair";
export interface Envelope {
nonce: string; // base64
ciphertext: string; // base64
}
const HEX_PUBKEY = /^[0-9a-f]{64}$/;
/** Does this targetSpec look like a direct-message pubkey? */
export function isDirectTarget(targetSpec: string): boolean {
return HEX_PUBKEY.test(targetSpec);
}
/**
* Encrypt a plaintext message addressed to a single recipient.
* Recipient's ed25519 pubkey (64 hex chars) is converted to X25519
* on the fly. Sender's full ed25519 secret key (128 hex chars) is
* also converted.
*/
export async function encryptDirect(
message: string,
recipientPubkeyHex: string,
senderSecretKeyHex: string,
): Promise<Envelope> {
const sodium = await ensureSodium();
const recipientPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(recipientPubkeyHex),
);
const senderSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(senderSecretKeyHex),
);
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),
};
}
/**
* Decrypt an inbound envelope from a known sender. Returns null if
* decryption fails (wrong keys, tampered ciphertext, malformed input).
*/
export async function decryptDirect(
envelope: Envelope,
senderPubkeyHex: string,
recipientSecretKeyHex: string,
): Promise<string | null> {
const sodium = await ensureSodium();
try {
const senderPub = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(senderPubkeyHex),
);
const recipientSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
sodium.from_hex(recipientSecretKeyHex),
);
const nonce = sodium.from_base64(
envelope.nonce,
sodium.base64_variants.ORIGINAL,
);
const ciphertext = sodium.from_base64(
envelope.ciphertext,
sodium.base64_variants.ORIGINAL,
);
const plain = sodium.crypto_box_open_easy(
ciphertext,
nonce,
senderPub,
recipientSec,
);
return sodium.to_string(plain);
} catch {
return null;
}
}

View File

@@ -74,13 +74,7 @@ function resolveClient(to: string): {
} }
function formatPush(p: InboundPush, meshSlug: string): string { function formatPush(p: InboundPush, meshSlug: string): string {
const body = (() => { const body = p.plaintext ?? "(decryption failed)";
try {
return Buffer.from(p.ciphertext, "base64").toString("utf-8");
} catch {
return "(invalid base64 ciphertext)";
}
})();
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`; return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
} }

View File

@@ -15,6 +15,11 @@
import WebSocket from "ws"; import WebSocket from "ws";
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import type { JoinedMesh } from "../state/config"; import type { JoinedMesh } from "../state/config";
import {
decryptDirect,
encryptDirect,
isDirectTarget,
} from "../crypto/envelope";
export type Priority = "now" | "next" | "low"; export type Priority = "now" | "next" | "low";
export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting"; export type ConnStatus = "connecting" | "open" | "closed" | "reconnecting";
@@ -28,6 +33,12 @@ export interface InboundPush {
ciphertext: string; ciphertext: string;
createdAt: string; createdAt: string;
receivedAt: string; receivedAt: string;
/** Decrypted plaintext (if encryption succeeded). null = broadcast
* or channel (no per-recipient crypto yet), or decryption failed. */
plaintext: string | null;
/** Hint for UI: "direct" (crypto_box), "channel"/"broadcast"
* (plaintext for now). */
kind: "direct" | "broadcast" | "channel" | "unknown";
} }
type PushHandler = (msg: InboundPush) => void; type PushHandler = (msg: InboundPush) => void;
@@ -157,8 +168,22 @@ export class BrokerClient {
priority: Priority = "next", priority: Priority = "next",
): Promise<{ ok: boolean; messageId?: string; error?: string }> { ): Promise<{ ok: boolean; messageId?: string; error?: string }> {
const id = randomId(); const id = randomId();
const nonce = randomNonce(); // Direct messages get crypto_box encryption; broadcasts + channels
const ciphertext = Buffer.from(message, "utf-8").toString("base64"); // still pass through as base64 plaintext until channel crypto lands.
let nonce: string;
let ciphertext: string;
if (isDirectTarget(targetSpec)) {
const env = await encryptDirect(
message,
targetSpec,
this.mesh.secretKey,
);
nonce = env.nonce;
ciphertext = env.ciphertext;
} else {
nonce = randomNonce();
ciphertext = Buffer.from(message, "utf-8").toString("base64");
}
return new Promise((resolve) => { return new Promise((resolve) => {
if (this.pendingSends.size >= MAX_QUEUED) { if (this.pendingSends.size >= MAX_QUEUED) {
@@ -254,18 +279,46 @@ export class BrokerClient {
return; return;
} }
if (msg.type === "push") { if (msg.type === "push") {
const nonce = String(msg.nonce ?? "");
const ciphertext = String(msg.ciphertext ?? "");
const senderPubkey = String(msg.senderPubkey ?? "");
// Decrypt asynchronously, then enqueue. Ordering within the
// buffer is preserved by awaiting before push.
void (async (): Promise<void> => {
const kind: InboundPush["kind"] = senderPubkey
? "direct"
: "unknown";
let plaintext: string | null = null;
if (senderPubkey && nonce && ciphertext) {
plaintext = await decryptDirect(
{ nonce, ciphertext },
senderPubkey,
this.mesh.secretKey,
);
}
// If decryption failed, fall back to base64 UTF-8 unwrap —
// this covers the legacy plaintext path for broadcasts/channels
// until channel crypto lands.
if (plaintext === null && ciphertext) {
try {
plaintext = Buffer.from(ciphertext, "base64").toString("utf-8");
} catch {
plaintext = null;
}
}
const push: InboundPush = { const push: InboundPush = {
messageId: String(msg.messageId ?? ""), messageId: String(msg.messageId ?? ""),
meshId: String(msg.meshId ?? ""), meshId: String(msg.meshId ?? ""),
senderPubkey: String(msg.senderPubkey ?? ""), senderPubkey,
priority: (msg.priority as Priority) ?? "next", priority: (msg.priority as Priority) ?? "next",
nonce: String(msg.nonce ?? ""), nonce,
ciphertext: String(msg.ciphertext ?? ""), ciphertext,
createdAt: String(msg.createdAt ?? ""), createdAt: String(msg.createdAt ?? ""),
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
plaintext,
kind,
}; };
this.pushBuffer.push(push); this.pushBuffer.push(push);
// Cap buffer at 500 entries to avoid unbounded growth.
if (this.pushBuffer.length > 500) this.pushBuffer.shift(); if (this.pushBuffer.length > 500) this.pushBuffer.shift();
for (const h of this.pushHandlers) { for (const h of this.pushHandlers) {
try { try {
@@ -274,6 +327,7 @@ export class BrokerClient {
/* handler errors are not the transport's problem */ /* handler errors are not the transport's problem */
} }
} }
})();
return; return;
} }
if (msg.type === "error") { if (msg.type === "error") {

View File

@@ -45,7 +45,14 @@ export const MobileNavigation = ({ links }: NavigationProps) => {
<> <>
<Hamburger open={open} onOpenChange={setOpen} className="lg:hidden" /> <Hamburger open={open} onOpenChange={setOpen} className="lg:hidden" />
<div className="pointer-events-none fixed top-14 left-0 z-10 flex h-[calc(100vh-3.5rem)] w-full flex-col gap-7 overflow-auto lg:hidden"> {/*
NOTE: do NOT put `overflow-auto`/`overflow-y-auto` on THIS container.
It's `fixed` + full-viewport + `pointer-events-none`, but a scroll
container on top of the page still steals wheel events on hover in
some browsers (Chrome/Safari inconsistently), breaking page scroll.
Move any needed scroll behavior to the inner panel below.
*/}
<div className="pointer-events-none fixed top-14 left-0 z-10 flex h-[calc(100vh-3.5rem)] w-full flex-col gap-7 lg:hidden">
<div <div
className={cn( className={cn(
"absolute inset-0 bg-black/80 opacity-0 transition-opacity duration-500 ease-out", "absolute inset-0 bg-black/80 opacity-0 transition-opacity duration-500 ease-out",
@@ -57,7 +64,7 @@ export const MobileNavigation = ({ links }: NavigationProps) => {
></div> ></div>
<div <div
className={cn( className={cn(
"bg-background flex w-full -translate-y-full flex-col gap-2 rounded-b-md px-[1.7rem] pb-6 transition-transform duration-500 ease-out sm:px-8", "bg-background flex max-h-full w-full -translate-y-full flex-col gap-2 overflow-y-auto rounded-b-md px-[1.7rem] pb-6 transition-transform duration-500 ease-out sm:px-8",
{ {
"pointer-events-auto translate-y-0": open, "pointer-events-auto translate-y-0": open,
}, },