← back to Paul Conrad Cartoons Shadowman
TK-12230: rebrand app to Inkwell — Editorial Cartoon Archive; API scrubs artist name from research records on the way out
1d63d18bb9aee732309757804abe248bc6b87dad · 2026-09-25 09:41:58 -0700 · Steve
Research data kept verbatim in data/cartoons.json; /api/cartoons neutralizes text
(name -> 'the artist') and withholds URLs/paths embedding the name; sendClean()
blocks any response still containing it. Adds /api/p24 route for imported art.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Files touched
M package.jsonM public/app.jsM public/index.htmlM server.js
Diff
commit 1d63d18bb9aee732309757804abe248bc6b87dad
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Sep 25 09:41:58 2026 -0700
TK-12230: rebrand app to Inkwell — Editorial Cartoon Archive; API scrubs artist name from research records on the way out
Research data kept verbatim in data/cartoons.json; /api/cartoons neutralizes text
(name -> 'the artist') and withholds URLs/paths embedding the name; sendClean()
blocks any response still containing it. Adds /api/p24 route for imported art.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---
package.json | 6 ++---
public/app.js | 8 +++---
public/index.html | 8 +++---
server.js | 80 +++++++++++++++++++++++++++++++++++++++++++++++++------
4 files changed, 83 insertions(+), 19 deletions(-)
diff --git a/package.json b/package.json
index 41711a0..7bba020 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
{
- "name": "paul-conrad-cartoons",
- "version": "0.2.0",
+ "name": "inkwell",
+ "version": "0.3.0",
"private": true,
- "description": "Private research catalog of Paul Conrad (1924-2010) editorial cartoons — Pulitzer-winning works, documented individual cartoons, and archival holding summaries. Local reference tool, not published.",
+ "description": "Inkwell — Editorial Cartoon Archive. Private local archive of original P24 photos and cartoons plus editorial-cartoon research records. Local reference tool, not published.",
"scripts": {
"start": "node server.js"
},
diff --git a/public/app.js b/public/app.js
index 99bda82..df3344e 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,4 +1,4 @@
-// Paul Conrad Cartoons — private catalog viewer.
+// Inkwell — Editorial Cartoon Archive: private catalog viewer.
// Loads /api/cartoons, renders a filterable/sortable grid with a density slider,
// a topic-chip + section + decade nav, and a detail modal. Choices persist to localStorage.
// No server-side state beyond the static dataset.
@@ -183,7 +183,7 @@ function renderDecadeJump() {
});
}
-// TK-12179 (Steve, 2026-09-24): Conrad cartoons are research-only and are NEVER displayed —
+// TK-12179 (Steve, 2026-09-24): the documented research cartoons are research-only and are NEVER displayed —
// not even locally. Records render as text; the original lives at its archive (citation link).
function cardHtml(r) {
let thumb;
@@ -270,7 +270,7 @@ function openBioModal() {
if (!BIO) return;
document.getElementById('modal-body').innerHTML = `
<button class="close-x" id="modal-close">×</button>
- <h2>${escapeHtml(BIO.name || 'Paul Conrad')}</h2>
+ <h2>${escapeHtml(BIO.name || 'The artist')}</h2>
<div class="modal-meta">${escapeHtml(BIO.born || '')} — ${escapeHtml(BIO.died || '')}</div>
<dl>
<dt>Education</dt><dd>${escapeHtml(BIO.education || '—')}</dd>
@@ -375,5 +375,5 @@ async function boot() {
}
boot().catch(err => {
- document.getElementById('grid').innerHTML = `<div class="empty-note">Could not load data/cartoons.json — ${escapeHtml(err.message)}</div>`;
+ document.getElementById('grid').innerHTML = `<div class="empty-note">Could not load the research records — ${escapeHtml(err.message)}</div>`;
});
diff --git a/public/index.html b/public/index.html
index ba35fd0..e57be9d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Paul Conrad Cartoons — a private research catalog</title>
+<title>Inkwell — Editorial Cartoon Archive</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
@@ -11,7 +11,7 @@
<div class="topnav-row1">
<button class="hamburger" id="hamburger" aria-label="Toggle menu" aria-expanded="false">☰</button>
<a class="wordmark" href="#" id="wordmark" data-section="all">
- Paul Conrad <span>Editorial Cartoons</span>
+ Inkwell <span>Editorial Cartoon Archive</span>
</a>
<div class="topnav-search">
<input id="search" type="search" placeholder="Search title, subject, date…">
@@ -19,7 +19,7 @@
</div>
<div class="topnav-row2" id="topnav-row2">
<div class="section-links" id="section-links">
- <button type="button" data-section="all" class="active">Cartoons</button>
+ <button type="button" data-section="all" class="active">Research records</button>
<button type="button" data-section="pulitzers">Pulitzers</button>
<button type="button" data-section="loc">Library of Congress</button>
<button type="button" data-section="archives">Archives</button>
@@ -38,7 +38,7 @@
<div class="topic-row" id="topic-row"></div>
</nav>
- <div class="masthead-sub">1924–2010 · Denver Post 1950–1964 · Los Angeles Times 1964–1993 · Three-time Pulitzer Prize winner (1964, 1971, 1984)</div>
+ <div class="masthead-sub">Private archive · editorial-cartoon research records · local reference tool, not published</div>
<div class="layout">
<aside class="filters">
diff --git a/server.js b/server.js
index 6f04dd8..dda6037 100644
--- a/server.js
+++ b/server.js
@@ -1,6 +1,11 @@
-// Paul Conrad Cartoons — private local reference catalog.
-// Minimal static server: serves public/ and the merged dataset at data/cartoons.json.
-// Local-only personal tool — no auth, not deployed.
+// Inkwell — Editorial Cartoon Archive. Private local reference tool (no auth, not deployed).
+// Serves public/, the research records (data/cartoons.json) and the imported P24 art (data/p24.json).
+//
+// TK-12230 naming rule: the served UI and every API response must never contain the name of the
+// real cartoonist the research records document. data/cartoons.json is kept verbatim as the
+// research source of truth; neutralizeRecords() scrubs it on the way OUT (text -> "the artist",
+// URLs/paths that embed the name are withheld), and sendClean() refuses to send any response
+// that still contains the name.
const express = require('express');
const path = require('path');
@@ -8,20 +13,79 @@ const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 9933;
+const BANNED = /conrad/i;
+
+function neutralText(s) {
+ return String(s)
+ .replace(/\bPaul\s+(Francis\s+)?Conrad['’]s\b/gi, "the artist's")
+ .replace(/\bPaul\s+(Francis\s+)?Conrad\b/gi, 'the artist')
+ .replace(/\bConrad\s+estate\b/gi, "the artist's estate")
+ .replace(/\bConrad['’]s\b/gi, "the artist's")
+ .replace(/\bConrad\b/gi, 'the artist')
+ .replace(/conrad/gi, 'artist');
+}
+
+function neutralize(value, key = '') {
+ if (typeof value === 'string') {
+ if (!BANNED.test(value)) return value;
+ // A URL or file path that embeds the name cannot be reworded without breaking it: withhold it.
+ if (/(url|image|href|src)$/i.test(key) || /^(https?:|\/|images\/|research\/)/i.test(value)) return null;
+ return neutralText(value);
+ }
+ if (Array.isArray(value)) return value.map(v => neutralize(v, key));
+ if (value && typeof value === 'object') {
+ const out = {};
+ for (const [k, v] of Object.entries(value)) out[k] = neutralize(v, k);
+ return out;
+ }
+ return value;
+}
+
+function neutralizeRecords(raw) {
+ const data = neutralize(raw);
+ if (data.bio) data.bio.name = 'The artist';
+ // Records whose image path was withheld still need the UI's "image not displayed" state.
+ for (const [i, r] of (data.records || []).entries()) {
+ const src = raw.records[i];
+ if ((src.image || src.image_url) && !r.image && !r.image_url) r.image = 'withheld';
+ }
+ return data;
+}
+
+function sendClean(res, obj) {
+ const body = JSON.stringify(obj);
+ if (BANNED.test(body)) return res.status(500).json({ error: 'response blocked: naming rule (TK-12230)' });
+ res.type('application/json').send(body);
+}
+
+function readJson(rel) {
+ return JSON.parse(fs.readFileSync(path.join(__dirname, rel), 'utf8'));
+}
app.use(express.static(path.join(__dirname, 'public')));
app.get('/api/cartoons', (req, res) => {
try {
- const raw = fs.readFileSync(path.join(__dirname, 'data', 'cartoons.json'), 'utf8');
- res.type('application/json').send(raw);
+ sendClean(res, neutralizeRecords(readJson('data/cartoons.json')));
+ } catch (err) {
+ res.status(500).json({ error: 'could not read research records', detail: neutralText(err.message) });
+ }
+});
+
+// All imported P24 photos + cartoons. Optional ?type=photo|cartoon.
+app.get('/api/p24', (req, res) => {
+ try {
+ const doc = readJson('data/p24.json');
+ const type = req.query.type;
+ const items = type ? doc.items.filter(i => i.type === type) : doc.items;
+ sendClean(res, { ...doc, items });
} catch (err) {
- res.status(500).json({ error: 'could not read data/cartoons.json', detail: err.message });
+ res.status(500).json({ error: 'could not read data/p24.json', detail: neutralText(err.message) });
}
});
-app.get('/health', (req, res) => res.json({ ok: true }));
+app.get('/health', (req, res) => res.json({ ok: true, app: 'inkwell' }));
app.listen(PORT, () => {
- console.log(`Paul Conrad Cartoons catalog running at http://localhost:${PORT}`);
+ console.log(`Inkwell — Editorial Cartoon Archive running at http://localhost:${PORT}`);
});
← f8ea2cd auto-data-snapshot: 2026-09-24T15:53:39 (49 data files) — re
·
back to Paul Conrad Cartoons Shadowman
·
TK-12230: import all P24 photos + cartoons (41 photos, 27 ca fad40a7 →