diff --git a/app/bookmarks/bookmarks.ts b/app/bookmarks/bookmarks.ts index 72e0c94a..4f8a3323 100644 --- a/app/bookmarks/bookmarks.ts +++ b/app/bookmarks/bookmarks.ts @@ -30,6 +30,12 @@ export const BOOKMARKS: Bookmarks = [ title: "The Beginning of Programming as We’ll Know It", url: "https://bitsplitting.org/2026/04/01/the-beginning-of-programming-as-well-know-it", }, + { + id: "ae3275ac-92a8-4377-b61a-a98ef909746f", + date: "2026-04-01", + title: "How Microsoft Vaporized a Trillion Dollars", + url: "https://isolveproblems.substack.com/p/how-microsoft-vaporized-a-trillion", + }, { id: "3345214d-d133-4fdf-89ba-1b9ab4e3fc7b", date: "2026-03-28", diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index b04693af..350a745a 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -20,6 +20,7 @@ export function Navbar() { const isHomePage = pathname === "/"; const isBookmarksPage = pathname === "/bookmarks"; const isBooksPage = pathname === "/books"; + const isCursorPage = pathname === "/cursor"; const isWritingPage = pathname === "/writing"; const isBlogPage = postsJson.posts.find((post: Post) => `/${post.id}` === pathname.split("#")[0]); @@ -46,6 +47,8 @@ export function Navbar() { titleText =

Books

; } else if (isWritingPage) { titleText =

Writing

; + } else if (isCursorPage) { + titleText =

Cursor

; } else if (isBlogPage) { const writingTitleWithId = `${isBlogPage.title} [#${isBlogPage.id}]`; titleText = getHeading(writingTitleWithId, HeadingLevel.H1); diff --git a/app/cursor/CursorAsciiPanel.tsx b/app/cursor/CursorAsciiPanel.tsx new file mode 100644 index 00000000..01f759d1 --- /dev/null +++ b/app/cursor/CursorAsciiPanel.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { memo, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; + +const COLS = 92; +const ROWS = 38; +const TOTAL = COLS * ROWS; + +const DITHER_BASE = "#030303"; +const DITHER_FLICKER = "#121212"; +const DITHER_CHAR = "·"; + +function subscribePrefersReducedMotion(onStoreChange: () => void) { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + mq.addEventListener("change", onStoreChange); + return () => mq.removeEventListener("change", onStoreChange); +} + +function getPrefersReducedMotionSnapshot() { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +function getPrefersReducedMotionServerSnapshot() { + return false; +} + +type Cell = { kind: "dither" } | { kind: "void" } | { kind: "art"; ch: string }; + +function pickFlickerSlots(total: number, count: number, allowed: Set): number[] { + const out: number[] = []; + const seen = new Set(); + let guard = 0; + while (out.length < count && guard < count * 120) { + guard += 1; + const i = Math.floor(Math.random() * total); + if (!allowed.has(i) || seen.has(i)) continue; + seen.add(i); + out.push(i); + } + return out; +} + +type FlickerParticle = { id: number; index: number; until: number }; + +function buildGrid(logo: string, wordmark: string): Cell[] { + const logoLines = logo.split("\n"); + const wordLines = wordmark.split("\n"); + const logoW = Math.max(...logoLines.map((l) => l.length), 0); + const wordW = Math.max(...wordLines.map((l) => l.length), 0); + const gap = 6; + const blockW = logoW + gap + wordW; + const startCol = Math.max(0, Math.floor((COLS - blockW) / 2)); + const startRow = Math.max(0, Math.floor((ROWS - Math.max(logoLines.length, wordLines.length)) / 2)); + + const grid: Cell[] = Array.from({ length: TOTAL }, () => ({ kind: "dither" as const })); + + const stamp = (lines: string[], row0: number, col0: number) => { + lines.forEach((line, dr) => { + const r = row0 + dr; + if (r < 0 || r >= ROWS) return; + for (let dc = 0; dc < line.length; dc += 1) { + const c = col0 + dc; + if (c < 0 || c >= COLS) continue; + const ch = line[dc]; + const idx = r * COLS + c; + if (ch === " ") grid[idx] = { kind: "void" }; + else grid[idx] = { kind: "art", ch }; + } + }); + }; + + stamp(logoLines, startRow, startCol); + stamp(wordLines, startRow, startCol + logoW + gap); + return grid; +} + +const BaseLayer = memo(function BaseLayer({ grid }: { grid: Cell[] }) { + return ( + <> + {grid.map((cell, i) => { + if (cell.kind === "art") { + return ( + + {cell.ch} + + ); + } + if (cell.kind === "void") { + return ( + + {"\u00a0"} + + ); + } + return ( + + {DITHER_CHAR} + + ); + })} + + ); +}); + +export function CursorAsciiPanel({ + logo, + wordmark, + "aria-label": ariaLabel, +}: { + logo: string; + wordmark: string; + "aria-label": string; +}) { + const reduceMotion = useSyncExternalStore( + subscribePrefersReducedMotion, + getPrefersReducedMotionSnapshot, + getPrefersReducedMotionServerSnapshot, + ); + + const grid = useMemo(() => buildGrid(logo, wordmark), [logo, wordmark]); + + const ditherSlots = useMemo(() => { + const s = new Set(); + for (let i = 0; i < grid.length; i += 1) { + if (grid[i].kind === "dither") s.add(i); + } + return s; + }, [grid]); + + const [particles, setParticles] = useState([]); + const nextId = useRef(0); + const [frameTime, setFrameTime] = useState(0); + + useEffect(() => { + if (reduceMotion) return; + + const spawnTick = () => { + const now = performance.now(); + setParticles((prev) => { + const alive = prev.filter((p) => p.until > now); + const taken = new Set(); + for (const p of alive) taken.add(p.index); + const allowed = new Set(); + for (const idx of ditherSlots) { + if (!taken.has(idx)) allowed.add(idx); + } + const n = 18 + Math.floor(Math.random() * 24); + const slots = pickFlickerSlots(TOTAL, n, allowed); + const born = slots.map((index) => { + nextId.current += 1; + return { + id: nextId.current, + index, + until: now + 60 + Math.random() * 95, + } satisfies FlickerParticle; + }); + return [...alive, ...born].slice(-150); + }); + }; + + const id = window.setInterval(spawnTick, 100); + spawnTick(); + return () => window.clearInterval(id); + }, [ditherSlots, reduceMotion]); + + useEffect(() => { + if (reduceMotion) return; + + const pulse = () => { + setFrameTime(performance.now()); + }; + pulse(); + const id = window.setInterval(pulse, 80); + return () => window.clearInterval(id); + }, [reduceMotion]); + + const visible = + reduceMotion || frameTime === 0 ? [] : particles.filter((p) => p.until > frameTime); + + const fontSize = "clamp(0.5rem, 1.1vw, 0.6875rem)"; + const lineHeight = 1.05; + + return ( +
+
+
+ + {visible.map((p) => { + const row = Math.floor(p.index / COLS); + const col = p.index % COLS; + return ( + + {DITHER_CHAR} + + ); + })} +
+
+ +

+ ASCII art: circular Cursor-style mark and the word cursor in block letters on a black field. The background is a + grid of near-black dots; a few dots briefly brighten for a subtle temporal dither effect. Respects + prefers-reduced-motion. +

+
+ ); +} diff --git a/app/cursor/page.tsx b/app/cursor/page.tsx new file mode 100644 index 00000000..4e2b8606 --- /dev/null +++ b/app/cursor/page.tsx @@ -0,0 +1,82 @@ +import type { Metadata } from "next"; +import { CursorAsciiPanel } from "./CursorAsciiPanel"; + +const DITHER_DOT = "·"; + +export const metadata: Metadata = { + title: "Cursor | Nathan Thomas", + description: "ASCII art homage to the Cursor logo and wordmark", + metadataBase: new URL("https://www.nathanthomas.dev"), + openGraph: { + title: "Cursor", + description: "ASCII art homage to the Cursor logo and wordmark", + url: "https://www.nathanthomas.dev/cursor", + siteName: "Nathan Thomas", + locale: "en_US", + type: "website", + images: [{ url: "/opengraph-image" }], + }, +}; + +// Reference-style shell: light edge (. , ' * /), heavy band (@ # % &). Spaces = true black cutout in the grid. +const LOGO = ` + . ' * / * ' , + , * @ # % & @ * ' . + ' / @ % # # % & @ / ' , + , * @ # @ @ @ @ @ # @ * ' . + . / @ # # @ / * ' + ' * @ # # @ * ' . + , / @ # # @ / * ' + * @ # # @ * ' + ' @ # # @ ' . + * @ # # @ * ' +' @ # # @ ' +* @ # # @ * +* @ # # @ * +' @ # # @ ' + * @ # # @ * ' + . * @ # # @ * ' . + ' / @ # # # # # # # # # # @ / * ' + , * @ % # # # # # # # % @ * ' . + . ' / @ % & % % & % @ / * ' . + , * @ # % % % # @ * ' , + . ' * @ @ @ * ' , . + . , ' * ' , . +`.trimStart(); + +// Lowercase block “cursor”; mixed glyphs for texture (inverted: reads light on black). +const WORDMARK = ` + @@@ @ @ @@@ @@@ @@@ @@@ + @ @ @ @ @ @ @ @ @ @ @ @ +@ @ @ @ @ @ @ @ @ @ +@ @ @ @@@ @ @ @@@@@ @@@@@ +@ @ @ @ @ @ @ @ @ + @ @ @ @ @ @ @ @ @ @ @ @ + @@@ @@@ @@@ @@@ @@@ @@@ +`.trimStart(); + +export default function CursorPage() { + return ( +
+

+ Inverted from a classic ASCII reference: light glyphs on a black field built from near-black{" "} + {DITHER_DOT} cells. A few background dots briefly step brighter for + a temporal dither. Inspired by{" "} + + Cursor + + . +

+ +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 2f1d1f0c..5071a973 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,7 +12,7 @@ export default async function Page() { return (

- I push the frontier of what's possible with autonomous, cloud-based AI coding agents at{" "} + I push the frontier of what's possible with cloud-based AI coding agents at{" "} Cursor {" "} diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.