diff --git a/CLAUDE.md b/CLAUDE.md index d79c08f..27c3378 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,7 +69,7 @@ Defense in depth against the npm-worm class (Shai-Hulud, mini-Shai-Hulud, the Ma `bun audit` is the source of truth for dependency advisories. State as of 2026-05-04: - **postcss `<8.5.10`** (GHSA-qx2v-qp2m-jg93, moderate XSS in CSS stringify). Multiple transitive resolutions — `next@16.2.4` pins `postcss@8.4.31` exactly, and `@tailwindcss/postcss@4.2.3` brings in `postcss@^8.5.6`. Resolved via `overrides.postcss = "8.5.10"` in `package.json`, which dedupes all transitives to the patched version. Drop the override after `next` and `@tailwindcss/postcss` ship releases that pull their transitives to ≥ 8.5.10. -- **mdast-util-to-hast `<13.2.1`** (GHSA-4fh9-h7wg-q85m, moderate XSS via unsanitized class attribute). Pulled in by three independent paths (shiki/rehype-pretty-code, react-markdown, velite/@mdx-js/mdx) — all parents accept `^13.0.0`, so the lockfile resolved to 13.2.0 (pre-fix). Resolved via `overrides.mdast-util-to-hast = "^13.2.1"`. Drop the override after parents ship releases that pull a patched version directly; verify with `bun pm ls --all | grep mdast-util-to-hast` showing only ≥ 13.2.1. +- **mdast-util-to-hast `<13.2.1`** (GHSA-4fh9-h7wg-q85m, moderate XSS via unsanitized class attribute). Pulled in by two independent paths (shiki/rehype-pretty-code, velite/@mdx-js/mdx) — both parents accept `^13.0.0`, so the lockfile resolved to 13.2.0 (pre-fix). Resolved via `overrides.mdast-util-to-hast = "^13.2.1"`. Drop the override after parents ship releases that pull a patched version directly; verify with `bun pm ls --all | grep mdast-util-to-hast` showing only ≥ 13.2.1. - **uuid `<14.0.0`** (GHSA-w5hq-g745-h8pq, moderate missing buffer bounds in v3/v5/v6 when `buf` provided). **Upstream-blocked.** Two parent paths: `resend@6.12.2 → svix@1.90.0 → uuid@^10.0.0` and `@lhci/cli@0.15.1 → uuid@8.3.2`. Neither parent admits a 14.x override without risking CJS imports. Exposure is theoretical on both: `/api/subscribe` uses Resend's send-email endpoint (not svix's webhook-signing path), `@lhci/cli` is dev-only and runs in CI on its own controlled inputs, and the vulnerable code (v3/v5/v6 with explicit `buf`) isn't called by either. Remove the `--ignore` when both parents ship releases bumping uuid to `^14.0.0`. - **tmp `<=0.2.3`** (GHSA-52f5-9888-hmc6, low symbolic-link path traversal in `dir` param). **Upstream-blocked.** Pulled exclusively by `@lhci/cli@0.15.1` (dev-only, runs in CI on controlled inputs). The symlink-traversal scenario doesn't apply. Remove the `--ignore` when `@lhci/cli` ships a release with patched transitives. diff --git a/bun.lock b/bun.lock index 10579fa..8584603 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,6 @@ "": { "name": "vortex", "dependencies": { - "@mdx-js/react": "^3.1.1", - "@next/mdx": "^16.2.4", "@tailwindcss/typography": "^0.5.19", "@vercel/analytics": "^2.0.1", "feed": "^5.2.1", @@ -15,10 +13,8 @@ "ogl": "^1.0.11", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-markdown": "^10.1.0", "rehype-pretty-code": "^0.14.3", "resend": "^6.12.2", - "use-scramble": "^2.2.15", "velite": "^0.3.1", }, "devDependencies": { @@ -195,12 +191,8 @@ "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], - "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], - "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], - "@next/mdx": ["@next/mdx@16.2.4", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-e/3bgla+/oF3vDlndI0eFPa0bnP47HPVA0InsAJi7Jr3DwV8WpEGuOcm/3PdI5/93FfNiBhMVeVHZpm1sFlmJw=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="], @@ -601,8 +593,6 @@ "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], - "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -921,8 +911,6 @@ "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], - "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], - "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], @@ -1101,8 +1089,6 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "use-scramble": ["use-scramble@2.2.15", "", { "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-3+ngTV6OpkY9JT75FM4JYcspmpP7cd/h0/5KqsMU2jcdBD+SAuBMmLqLu0N3/7t8NsfdwJjD3BBeKtT+qZWKew=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], diff --git a/package.json b/package.json index d649250..6a1832c 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,6 @@ "test:e2e:install": "playwright install --with-deps chromium webkit" }, "dependencies": { - "@mdx-js/react": "^3.1.1", - "@next/mdx": "^16.2.4", "@tailwindcss/typography": "^0.5.19", "@vercel/analytics": "^2.0.1", "feed": "^5.2.1", @@ -33,10 +31,8 @@ "ogl": "^1.0.11", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-markdown": "^10.1.0", "rehype-pretty-code": "^0.14.3", "resend": "^6.12.2", - "use-scramble": "^2.2.15", "velite": "^0.3.1" }, "devDependencies": { diff --git a/src/components/link/index.tsx b/src/components/link/index.tsx index 6ed0ad4..479b307 100644 --- a/src/components/link/index.tsx +++ b/src/components/link/index.tsx @@ -1,28 +1,71 @@ "use client"; import NextLink from "next/link"; -import { useScramble } from "use-scramble"; +import { useCallback, useEffect, useRef } from "react"; interface LinkProps extends React.ComponentProps { children: string; } +const SCRAMBLE_CHARS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const SCRAMBLE_WINDOW = 4; +const FRAME_MS = 30; + export const Link = ({ href, children, ...props }: LinkProps) => { - const { ref, replay } = useScramble({ - text: children, - speed: 0.5, - tick: 1, - step: 1, - scramble: 4, - seed: 0, - playOnMount: false - }); + const nodeRef = useRef(null); + const frameRef = useRef(null); + + const setNode = useCallback((node: HTMLAnchorElement | null) => { + nodeRef.current = node; + }, []); + + useEffect( + () => () => { + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + }, + [] + ); + + const replay = useCallback(() => { + const el = nodeRef.current; + if (!el) return; + if (frameRef.current !== null) return; + const target = children; + let revealed = 0; + let lastTime = 0; + const tick = (now: number) => { + if (now - lastTime >= FRAME_MS) { + lastTime = now; + if (revealed >= target.length) { + el.textContent = target; + frameRef.current = null; + return; + } + const head = target.slice(0, revealed); + let tail = ""; + const tailLen = Math.min(SCRAMBLE_WINDOW, target.length - revealed); + for (let i = 0; i < tailLen; i++) { + const ch = target.charAt(revealed + i); + tail += /\s/.test(ch) + ? ch + : SCRAMBLE_CHARS.charAt( + (Math.random() * SCRAMBLE_CHARS.length) | 0 + ); + } + el.textContent = head + tail; + revealed += 1; + } + frameRef.current = requestAnimationFrame(tick); + }; + frameRef.current = requestAnimationFrame(tick); + }, [children]); return ( diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 9efb3e9..9c436b3 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -99,6 +99,36 @@ test.describe("blog post", () => { }); }); +test.describe("footer", () => { + test("custom Link scramble settles back to original on hover", async ({ + page + }) => { + await page.goto("/"); + const link = page.locator('a[href="https://lfprojects.org"]').first(); + await expect(link).toBeVisible(); + const original = (await link.textContent()) ?? ""; + + // Trigger a real pointer enter — the scramble fires on hover. + await link.hover(); + + // Sample intermediate frames: the in-flight animation must produce at + // least one text snapshot that differs from the static text. Without + // the in-flight guard in the Link component, the scramble would loop + // forever because mutating textContent re-fires mouseover. + const observed = new Set(); + for (let i = 0; i < 30; i++) { + observed.add((await link.textContent()) ?? ""); + await page.waitForTimeout(20); + } + + // Wait for the animation to settle, then assert it returned to the + // original string (regression check for the textContent-refire loop). + await page.waitForTimeout(800); + expect(await link.textContent()).toBe(original); + expect([...observed].some((s) => s !== original)).toBe(true); + }); +}); + test.describe("mobile layout", () => { test.use({ viewport: { width: 375, height: 812 } });