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.