diff --git a/public/404.html b/public/404.html
new file mode 100644
index 0000000..5614acd
--- /dev/null
+++ b/public/404.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+ __SEO_TITLE__
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ERROR_404 // NODE_UNREACHABLE
+
+
+
+
+

+
+
+
+
+
+
+
diff --git a/public/assets/app.js b/public/assets/app.js
index 85e6e84..afe2cef 100644
--- a/public/assets/app.js
+++ b/public/assets/app.js
@@ -253,6 +253,13 @@ function renderMemes(memes, options = {}) {
}
async function openSharedContentFromUrl() {
+ const requestedDialog = new URLSearchParams(window.location.search).get('open');
+ if (window.location.pathname === '/' && requestedDialog === 'pet') {
+ petModal.showModal();
+ window.history.replaceState({}, '', '/');
+ return;
+ }
+
if (window.location.pathname === '/lore' || window.location.pathname === '/lore/') {
await openLoreDialog();
return;
diff --git a/public/assets/homo-reseticus-404.png b/public/assets/homo-reseticus-404.png
new file mode 100644
index 0000000..feced4d
Binary files /dev/null and b/public/assets/homo-reseticus-404.png differ
diff --git a/public/assets/styles.css b/public/assets/styles.css
index a61e493..e17fa74 100644
--- a/public/assets/styles.css
+++ b/public/assets/styles.css
@@ -91,6 +91,15 @@ a {
letter-spacing: 0.2em;
}
+.brand-link {
+ text-decoration: none;
+}
+
+.brand-link:hover,
+.brand-link:focus-visible {
+ color: var(--text);
+}
+
.primary-action,
.secondary-action,
.icon-action {
@@ -925,6 +934,75 @@ dialog::backdrop {
text-align: center;
}
+.not-found-page {
+ min-height: 100svh;
+ overflow-x: hidden;
+}
+
+.not-found-shell {
+ display: grid;
+ grid-template-rows: auto 1fr;
+ min-height: 100svh;
+}
+
+.not-found-content {
+ display: grid;
+ width: min(100%, 760px);
+ justify-items: center;
+ place-self: center;
+ padding: 24px 0 48px;
+ text-align: center;
+}
+
+.not-found-visual {
+ position: relative;
+ isolation: isolate;
+ width: clamp(190px, 39vmin, 414px);
+ max-width: 100%;
+}
+
+.not-found-page .pulse {
+ background: #ff453a;
+ box-shadow: 0 0 8px rgb(255 69 58 / 55%);
+}
+
+.not-found-visual::before {
+ position: absolute;
+ z-index: -1;
+ inset: 7% 5% 6%;
+ border-radius: 50%;
+ background:
+ radial-gradient(
+ ellipse at 50% 47%,
+ rgb(255 255 255 / 58%) 0%,
+ rgb(244 248 244 / 36%) 36%,
+ rgb(211 224 213 / 16%) 58%,
+ transparent 76%
+ ),
+ radial-gradient(
+ ellipse at 50% 48%,
+ rgb(192 216 198 / 12%) 0%,
+ transparent 72%
+ );
+ content: "";
+ filter: blur(36px);
+ transform: scale(1.24);
+}
+
+.not-found-visual img {
+ display: block;
+ width: 100%;
+ height: auto;
+ filter: contrast(1.06) drop-shadow(0 0 18px rgb(229 226 225 / 14%));
+}
+
+.not-found-action {
+ display: inline-flex;
+ min-height: 40px;
+ align-items: center;
+ justify-content: center;
+}
+
[hidden] {
display: none !important;
}
@@ -1048,3 +1126,9 @@ dialog::backdrop {
justify-content: space-between;
}
}
+
+@media (prefers-reduced-motion: reduce) {
+ .not-found-page .pulse {
+ animation: none;
+ }
+}
diff --git a/server.js b/server.js
index b7accf2..0982421 100644
--- a/server.js
+++ b/server.js
@@ -11,7 +11,7 @@ import { seedDemoMemes } from './src/seed.js';
import { moderateImage } from './src/moderation.js';
import { createUploadLimiter } from './src/uploadLimits.js';
import { createIpRateLimiter } from './src/rateLimits.js';
-import { feedJson, llmsTxt, manifest, noIndex, publicBaseUrl, renderIndex, robotsTxt, sitemapXml } from './src/seo.js';
+import { feedJson, llmsTxt, manifest, noIndex, publicBaseUrl, renderIndex, renderNotFound, robotsTxt, sitemapXml } from './src/seo.js';
await loadDotEnv();
@@ -36,6 +36,7 @@ const DISCOVERY_ROUTES = new Set([
]);
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex');
const indexTemplate = await fs.readFile('./public/index.html', 'utf8');
+const notFoundTemplate = await fs.readFile('./public/404.html', 'utf8');
const openApiSpec = JSON.parse(await fs.readFile('./openapi.json', 'utf8'));
const memeApiSkillTemplate = await fs.readFile('./meme-api.skill.md', 'utf8');
@@ -90,7 +91,9 @@ const server = http.createServer(async (req, res) => {
const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
if (req.method === 'GET' && memePageMatch) {
const meme = store.get(memePageMatch[1]);
- if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
+ if (!meme || meme.status !== 'approved') {
+ return sendNotFoundPage(res, { baseUrl, pathname: url.pathname });
+ }
const nonce = crypto.randomBytes(16).toString('base64');
withSecurityHeaders(res, { scriptNonce: nonce });
return sendHtml(res, 200, renderIndex({
@@ -292,6 +295,9 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, { ok: true });
}
+ if (isBrowserPageRequest(req, url)) {
+ return sendNotFoundPage(res, { baseUrl, pathname: url.pathname });
+ }
return sendText(res, 404, 'Not found');
} catch (error) {
const status = error.statusCode || 500;
@@ -323,6 +329,27 @@ function isDiscoveryRoute(req, url) {
return DISCOVERY_ROUTES.has(url.pathname);
}
+function isBrowserPageRequest(req, url) {
+ if (req.method !== 'GET') return false;
+ return ![
+ '/api',
+ '/assets',
+ '/media',
+ '/download',
+ '/admin',
+ '/admin-media'
+ ].some((prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`));
+}
+
+function sendNotFoundPage(res, { baseUrl, pathname }) {
+ noIndex(res);
+ return sendHtml(res, 404, renderNotFound({
+ template: notFoundTemplate,
+ baseUrl,
+ pathname
+ }));
+}
+
async function loadDotEnv() {
let content = '';
try {
diff --git a/src/seo.js b/src/seo.js
index 6aef0f0..c91aca2 100644
--- a/src/seo.js
+++ b/src/seo.js
@@ -2,6 +2,7 @@ const SITE_NAME = 'The Meme Protocol';
const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.';
const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree';
const SOCIAL_IMAGE_PATH = '/assets/social-preview-v1.png';
+const NOT_FOUND_IMAGE_PATH = '/assets/homo-reseticus-404.png';
const FAVICON_PATH = '/assets/favicon';
const FAVICON_VERSION = '20260814';
@@ -103,6 +104,21 @@ export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = nu
.replaceAll('__CSP_NONCE__', nonce);
}
+export function renderNotFound({ template, baseUrl, pathname }) {
+ const title = `404 | ${SITE_NAME}`;
+ const description = 'The requested Meme Protocol node could not be found.';
+ const pageUrl = new URL(pathname, `${baseUrl}/`).href;
+ const image = `${baseUrl}${NOT_FOUND_IMAGE_PATH}`;
+ const imageAlt = 'Homo Reseticus surrounded by an unreachable node pattern';
+
+ return template
+ .replaceAll('__SEO_TITLE__', escapeHtml(title))
+ .replaceAll('__SEO_DESCRIPTION__', escapeHtml(description))
+ .replaceAll('__SEO_URL__', escapeHtml(pageUrl))
+ .replaceAll('__SEO_IMAGE__', escapeHtml(image))
+ .replaceAll('__SEO_IMAGE_ALT__', escapeHtml(imageAlt));
+}
+
export function robotsTxt(baseUrl) {
return [
'User-agent: *',
diff --git a/tests/notFound.test.js b/tests/notFound.test.js
new file mode 100644
index 0000000..8558e92
--- /dev/null
+++ b/tests/notFound.test.js
@@ -0,0 +1,50 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import { renderNotFound } from '../src/seo.js';
+
+const template = fs.readFileSync(new URL('../public/404.html', import.meta.url), 'utf8');
+const page = renderNotFound({
+ template,
+ baseUrl: 'https://memes.example',
+ pathname: '/missing-node'
+});
+const styles = fs.readFileSync(new URL('../public/assets/styles.css', import.meta.url), 'utf8');
+const artwork = fs.readFileSync(new URL('../public/assets/homo-reseticus-404.png', import.meta.url));
+
+test('404 page is noindexed and links visitors back to the live feed', () => {
+ assert.match(page, /404 \| The Meme Protocol<\/title>/);
+ assert.match(page, //);
+ assert.match(page, /class="primary-action not-found-action" href="\/"/);
+ assert.doesNotMatch(page, /PAGE_NOT_FOUND/);
+ assert.doesNotMatch(page, /THE_REQUESTED_NODE_DOES_NOT_EXIST/);
+});
+
+test('404 page carries adjusted site discovery and social metadata', () => {
+ assert.match(page, //);
+ assert.match(page, //);
+ assert.match(page, /]+href="\/openapi\.json">/);
+ assert.match(page, /]+href="\/meme-api\.skill\.md">/);
+ assert.match(page, //);
+ assert.match(page, //);
+ assert.match(page, //);
+ assert.doesNotMatch(page, / {
+ assert.match(page, /class="status-line"/);
+ assert.match(page, />DOWNLOAD PET<\/a>/);
+ assert.match(page, />READ LORE<\/a>/);
+ assert.match(page, /ERROR_404 \/\/ NODE_UNREACHABLE/);
+});
+
+test('404 page uses the supplied artwork with a diffused backdrop', () => {
+ assert.match(page, /src="\/assets\/homo-reseticus-404\.png"/);
+ assert.match(page, /alt="Homo Reseticus surrounded by an unreachable node pattern"/);
+ assert.match(styles, /\.not-found-visual::before/);
+ assert.match(styles, /radial-gradient\(/);
+ assert.match(styles, /filter: blur\(36px\)/);
+ assert.match(styles, /transform: scale\(1\.24\)/);
+ assert.match(styles, /\.not-found-page \.pulse/);
+ assert.equal(artwork.subarray(1, 4).toString('ascii'), 'PNG');
+});