[object Object]

← back to Paul Conrad Cartoons Shadowman

TK-12230: P24 Art gallery — all 68 photos + cartoons inline with type filter, sort, density slider, date+time chips

17318de7fd7fcb4ab63604a4c5af73738b528c7a · 2026-09-25 09:48:08 -0700 · Steve

Filter/sort/density persist to localStorage; every card shows a created date+time chip
(ISO in title=); click opens a detail modal with full art + extracted panels. Styles live
in public/style.css only. scripts/verify-inkwell.cjs is the E2E check (22 assertions,
incl. a negative self-test of the name detector).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 17318de7fd7fcb4ab63604a4c5af73738b528c7a
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Sep 25 09:48:08 2026 -0700

    TK-12230: P24 Art gallery — all 68 photos + cartoons inline with type filter, sort, density slider, date+time chips
    
    Filter/sort/density persist to localStorage; every card shows a created date+time chip
    (ISO in title=); click opens a detail modal with full art + extracted panels. Styles live
    in public/style.css only. scripts/verify-inkwell.cjs is the E2E check (22 assertions,
    incl. a negative self-test of the name detector).
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---
 public/index.html          |  34 ++++++++++++++
 public/p24.js              |  97 ++++++++++++++++++++++++++++++++++++++
 public/style.css           |  59 +++++++++++++++++++++++
 scripts/verify-inkwell.cjs | 113 +++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 303 insertions(+)

diff --git a/public/index.html b/public/index.html
index e57be9d..14afed9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -19,6 +19,7 @@
     </div>
     <div class="topnav-row2" id="topnav-row2">
       <div class="section-links" id="section-links">
+        <a class="nav-anchor" href="#p24">P24 Art</a>
         <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>
@@ -40,6 +41,38 @@
 
   <div class="masthead-sub">Private archive · editorial-cartoon research records · local reference tool, not published</div>
 
+  <section class="p24" id="p24" aria-labelledby="p24-heading">
+    <div class="p24-head">
+      <h2 id="p24-heading">P24 Art <span>original photos &amp; cartoons</span></h2>
+    </div>
+    <div class="controls p24-controls">
+      <div class="p24-filter" id="p24-filter" role="group" aria-label="Filter by type">
+        <button type="button" data-type="all">All</button>
+        <button type="button" data-type="photo">Photos</button>
+        <button type="button" data-type="cartoon">Cartoons</button>
+      </div>
+      <div>
+        <label for="p24-sort">Sort</label>
+        <select id="p24-sort">
+          <option value="newest">Newest</option>
+          <option value="oldest">Oldest</option>
+          <option value="title-az">Title A→Z</option>
+          <option value="type">Type</option>
+        </select>
+      </div>
+      <div class="density-wrap">
+        <label for="p24-density">Density</label>
+        <input type="range" id="p24-density" min="140" max="440" step="10">
+      </div>
+      <div class="result-count" id="p24-count"></div>
+    </div>
+    <div class="grid-wrap">
+      <div class="grid p24-grid" id="p24-grid"></div>
+    </div>
+  </section>
+
+  <h2 class="section-heading">Research records <span>text-only; images never displayed</span></h2>
+
   <div class="layout">
     <aside class="filters">
       <details class="filter-group" id="facet-award" open></details>
@@ -80,5 +113,6 @@
   </div>
 
   <script src="app.js"></script>
+  <script src="p24.js"></script>
 </body>
 </html>
diff --git a/public/p24.js b/public/p24.js
new file mode 100644
index 0000000..6e77e13
--- /dev/null
+++ b/public/p24.js
@@ -0,0 +1,97 @@
+// Inkwell — P24 Art gallery (TK-12230).
+// Loads /api/p24 and renders every imported photo + cartoon INLINE with a type filter,
+// a sort select, and a density slider. All three persist to localStorage.
+// Every card carries a created date+time chip (admin rule) with the ISO value in title=.
+
+(function () {
+  const LS = { type: 'inkwell:p24:type', sort: 'inkwell:p24:sort', density: 'inkwell:p24:density' };
+  const get = (k, d) => { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } };
+  const set = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* ignore */ } };
+  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+  const fmtDate = (iso) => {
+    const d = new Date(iso);
+    return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+  };
+
+  let ITEMS = [];
+  let state = { type: get(LS.type, 'all'), sort: get(LS.sort, 'newest'), density: get(LS.density, 240) };
+
+  function sorted(list) {
+    const a = [...list];
+    const t = (x) => new Date(x.date).getTime() || 0;
+    switch (state.sort) {
+      case 'oldest': return a.sort((x, y) => t(x) - t(y));
+      case 'title-az': return a.sort((x, y) => x.title.localeCompare(y.title));
+      case 'type': return a.sort((x, y) => x.type.localeCompare(y.type) || t(y) - t(x));
+      case 'newest':
+      default: return a.sort((x, y) => t(y) - t(x));
+    }
+  }
+
+  function card(it) {
+    const panels = it.panels && it.panels.length
+      ? `<div class="panels">${it.panels.map((p, i) => `<img src="${esc(p)}" alt="${esc(it.title)} — panel ${i + 1}" loading="lazy">`).join('')}</div>`
+      : '';
+    return `<article class="card p24-card ${esc(it.type)}" data-id="${esc(it.id)}" tabindex="0">
+      <div class="thumb"><img src="${esc(it.src)}" alt="${esc(it.title)}" loading="lazy"></div>
+      <div class="body">
+        <div class="title">${esc(it.title)}</div>
+        <span class="when" title="${esc(it.date)}">🕓 ${esc(fmtDate(it.date))}</span>
+        ${it.caption ? `<div class="caption">${esc(it.caption)}</div>` : ''}
+        ${panels}
+        <div class="badges"><span class="badge ${esc(it.type)}">${it.type === 'photo' ? 'Photo' : 'Cartoon'}</span>${it.category ? `<span class="topic-tag">${esc(it.category)}</span>` : ''}</div>
+      </div>
+    </article>`;
+  }
+
+  function render() {
+    document.querySelectorAll('#p24-filter button').forEach(b => b.classList.toggle('active', b.dataset.type === state.type));
+    const list = sorted(ITEMS.filter(i => state.type === 'all' || i.type === state.type));
+    document.getElementById('p24-count').textContent = `${list.length} of ${ITEMS.length}`;
+    const grid = document.getElementById('p24-grid');
+    grid.innerHTML = list.length ? list.map(card).join('') : '<div class="empty-note">Nothing matches this filter.</div>';
+    grid.querySelectorAll('.p24-card').forEach(c => {
+      c.addEventListener('click', () => open(c.dataset.id));
+      c.addEventListener('keydown', (e) => { if (e.key === 'Enter') open(c.dataset.id); });
+    });
+  }
+
+  function open(id) {
+    const it = ITEMS.find(x => x.id === id);
+    if (!it) return;
+    const panels = it.panels && it.panels.length
+      ? `<div class="p24-panels">${it.panels.map((p, i) => `<img src="${esc(p)}" alt="${esc(it.title)} — panel ${i + 1}">`).join('')}</div>` : '';
+    document.getElementById('modal-body').innerHTML = `
+      <button class="close-x" id="modal-close">&times;</button>
+      <h2>${esc(it.title)}</h2>
+      <div class="modal-meta"><span class="when" title="${esc(it.date)}">🕓 ${esc(fmtDate(it.date))}</span> · ${it.type === 'photo' ? 'Photo' : 'Cartoon'}${it.category ? ' · ' + esc(it.category) : ''}</div>
+      <img class="p24-full" src="${esc(it.src)}" alt="${esc(it.title)}">
+      ${panels}
+      ${it.caption ? `<p>${esc(it.caption)}</p>` : ''}
+      <dl><dt>Source</dt><dd>P24 · ${esc(it.source_page)}</dd></dl>`;
+    document.getElementById('modal-backdrop').classList.add('open');
+    document.getElementById('modal-close').addEventListener('click', () => document.getElementById('modal-backdrop').classList.remove('open'));
+  }
+
+  function init() {
+    const sortEl = document.getElementById('p24-sort');
+    const densEl = document.getElementById('p24-density');
+    sortEl.value = state.sort;
+    densEl.value = state.density;
+    document.documentElement.style.setProperty('--p24-min', state.density + 'px');
+    sortEl.addEventListener('change', () => { state.sort = sortEl.value; set(LS.sort, state.sort); render(); });
+    densEl.addEventListener('input', () => {
+      state.density = Number(densEl.value);
+      document.documentElement.style.setProperty('--p24-min', state.density + 'px');
+      set(LS.density, state.density);
+    });
+    document.querySelectorAll('#p24-filter button').forEach(b => b.addEventListener('click', () => {
+      state.type = b.dataset.type; set(LS.type, state.type); render();
+    }));
+  }
+
+  fetch('/api/p24')
+    .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
+    .then(doc => { ITEMS = doc.items || []; init(); render(); })
+    .catch(err => { document.getElementById('p24-grid').innerHTML = `<div class="empty-note">Could not load P24 art — ${esc(err.message)}</div>`; });
+})();
diff --git a/public/style.css b/public/style.css
index da34367..6f252e1 100644
--- a/public/style.css
+++ b/public/style.css
@@ -419,3 +419,62 @@ body {
   .grid-wrap { padding: 14px; }
   .modal { padding: 18px 16px; max-height: 92vh; }
 }
+
+/* ---- P24 Art gallery (TK-12230) ---- */
+.section-links .nav-anchor {
+  color: var(--bg);
+  text-decoration: none;
+  font-size: 13px;
+  padding: 5px 10px;
+  border: 1px solid var(--accent);
+  border-radius: var(--radius);
+  align-self: center;
+}
+.section-links .nav-anchor:hover { background: var(--accent); }
+.p24 { border-bottom: 3px solid var(--accent); }
+.p24-head, .section-heading { padding: 16px 20px 4px; }
+.p24-head h2, .section-heading { margin: 0; font-size: 22px; }
+.p24-head h2 span, .section-heading span { font-size: 13px; font-weight: 400; font-style: italic; color: var(--ink-soft); margin-left: 8px; }
+.section-heading { padding-bottom: 12px; border-bottom: 1px solid var(--line); }
+.p24-controls { position: static; }
+.p24-filter { display: flex; gap: 4px; }
+.p24-filter button {
+  font-family: inherit;
+  font-size: 12.5px;
+  padding: 5px 12px;
+  border: 1px solid var(--line);
+  border-radius: 14px;
+  background: var(--bg);
+  color: var(--ink);
+  cursor: pointer;
+}
+.p24-filter button.active { background: var(--ink); color: var(--bg); border-color: var(--ink); }
+.p24-grid { grid-template-columns: repeat(auto-fill, minmax(var(--p24-min, 240px), 1fr)); }
+.p24-card .thumb { background: #111; }
+.p24-card .thumb img { object-fit: cover; }
+.p24-card.cartoon .thumb img { object-fit: contain; }
+.p24-card .caption {
+  font-size: 12px;
+  color: var(--ink-soft);
+  line-height: 1.35;
+  display: -webkit-box;
+  -webkit-line-clamp: 3;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+.p24-card .panels { display: flex; gap: 4px; }
+.p24-card .panels img { width: 48px; height: 48px; object-fit: cover; border: 1px solid var(--line); border-radius: 2px; background: #111; }
+.when {
+  font-size: 11px;
+  color: var(--ink-soft);
+  background: var(--bg);
+  border: 1px solid var(--line);
+  border-radius: 10px;
+  padding: 1px 8px;
+  align-self: flex-start;
+}
+.badge.photo { background: var(--badge-doc); }
+.badge.cartoon { background: var(--accent); }
+.modal .p24-full { width: 100%; height: auto; display: block; margin: 12px 0; border: 1px solid var(--line); background: #111; }
+.modal .p24-panels { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-bottom: 12px; }
+.modal .p24-panels img { width: 100%; height: auto; border: 1px solid var(--line); background: #111; }
diff --git a/scripts/verify-inkwell.cjs b/scripts/verify-inkwell.cjs
new file mode 100644
index 0000000..baa3c5b
--- /dev/null
+++ b/scripts/verify-inkwell.cjs
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+// verify-inkwell.cjs — E2E check of the Inkwell app (TK-12230). $0, local.
+// Usage: BASE=http://localhost:9947 SHOTS=/path/to/dir node scripts/verify-inkwell.cjs
+// Exit 0 only if every assertion passes. Includes a negative self-test proving the name
+// detector goes red on an injected fault (a check that cannot fail proves nothing).
+const path = require('path');
+const fs = require('fs');
+const { chromium } = require(process.env.PLAYWRIGHT_PATH || path.join(process.env.HOME, 'Projects/jevrun-runner/node_modules/playwright'));
+
+const BASE = process.env.BASE || 'http://localhost:9947';
+const SHOTS = process.env.SHOTS || path.join(__dirname, '..', 'shots');
+const NAME = new RegExp(['con', 'rad'].join(''), 'i');
+const results = [];
+const check = (name, ok, detail = '') => { results.push({ name, ok: !!ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'}  ${name}${detail ? '  — ' + detail : ''}`); };
+
+(async () => {
+  fs.mkdirSync(SHOTS, { recursive: true });
+  // Negative self-test: the detector must flag an injected occurrence.
+  check('negative self-test: name detector flags injected text', NAME.test('cartoon by P. ' + 'Con' + 'rad') && !NAME.test('Inkwell archive'));
+
+  const p24 = await (await fetch(BASE + '/api/p24')).json();
+  const expected = p24.items.length;
+  const expectedImgs = p24.items.reduce((n, i) => n + 1 + (i.panels || []).length, 0);
+  check('/api/p24 has items', expected > 0, `${p24.counts.photo} photos + ${p24.counts.cartoon} cartoons = ${expected}; ${expectedImgs} images incl. panels`);
+  for (const ep of ['/api/p24', '/api/cartoons', '/health', '/', '/app.js', '/p24.js', '/style.css']) {
+    const r = await fetch(BASE + ep);
+    const body = await r.text();
+    check(`${ep} 200 and name-free`, r.status === 200 && !NAME.test(body), `status ${r.status}`);
+  }
+
+  const browser = await chromium.launch();
+  const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } });
+  const page = await ctx.newPage();
+  const errors = [];
+  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+  page.on('pageerror', e => errors.push(String(e)));
+  const resp = await page.goto(BASE + '/', { waitUntil: 'networkidle' });
+  check('page loads 200', resp.status() === 200);
+  await page.waitForSelector('.p24-card');
+
+  // Force every lazy image to load, then assert each rendered.
+  await page.evaluate(() => document.querySelectorAll('#p24-grid img').forEach(i => { i.loading = 'eager'; }));
+  for (let y = 0; y < 60; y++) { await page.mouse.wheel(0, 1500); await page.waitForTimeout(60); }
+  await page.waitForFunction(() => [...document.querySelectorAll('#p24-grid img')].every(i => i.complete), null, { timeout: 30000 });
+  const imgs = await page.evaluate(() => [...document.querySelectorAll('#p24-grid img')].map(i => ({ src: i.getAttribute('src'), w: i.naturalWidth })));
+  const broken = imgs.filter(i => !(i.w > 0));
+  check('every gallery image rendered (naturalWidth>0)', imgs.length === expectedImgs && broken.length === 0, `${imgs.length}/${expectedImgs} rendered, broken=${broken.length}${broken.length ? ' ' + broken.slice(0, 3).map(b => b.src).join(',') : ''}`);
+  let bad = 0;
+  for (const i of imgs) { const r = await fetch(BASE + i.src); if (r.status !== 200) bad++; }
+  check('every gallery image HTTP 200', bad === 0, `${imgs.length - bad}/${imgs.length}`);
+  const cardCount = await page.locator('.p24-card').count();
+  check('card count = expected total', cardCount === expected, `${cardCount}/${expected}`);
+  const chips = await page.evaluate(() => [...document.querySelectorAll('.p24-card .when')].map(w => ({ t: w.textContent, iso: w.title })));
+  check('every card has date+time chip with ISO title', chips.length === expected && chips.every(c => /\d{4}/.test(c.t) && /:\d{2}/.test(c.t) && !isNaN(Date.parse(c.iso))), chips[0] && chips[0].t);
+  await page.evaluate(() => window.scrollTo(0, 0));
+  await page.screenshot({ path: path.join(SHOTS, 'inkwell-gallery-all.png') });
+  await page.screenshot({ path: path.join(SHOTS, 'inkwell-full.png'), fullPage: true });
+
+  // Filter
+  await page.click('#p24-filter button[data-type="cartoon"]');
+  const nCart = await page.locator('.p24-card').count();
+  check('filter Cartoons', nCart === p24.counts.cartoon && await page.locator('.p24-card.photo').count() === 0, `${nCart}`);
+  await page.screenshot({ path: path.join(SHOTS, 'inkwell-cartoons.png') });
+  await page.click('#p24-filter button[data-type="photo"]');
+  const nPhoto = await page.locator('.p24-card').count();
+  check('filter Photos', nPhoto === p24.counts.photo, `${nPhoto}`);
+  // Sort
+  await page.selectOption('#p24-sort', 'title-az');
+  const titles = await page.$$eval('.p24-card .title', els => els.map(e => e.textContent));
+  check('sort Title A→Z', titles.join('|') === [...titles].sort((a, b) => a.localeCompare(b)).join('|'), titles.slice(0, 2).join(' / '));
+  // Density
+  await page.$eval('#p24-density', el => { el.value = '160'; el.dispatchEvent(new Event('input', { bubbles: true })); });
+  const cols160 = await page.$eval('#p24-grid', g => getComputedStyle(g).gridTemplateColumns.split(' ').length);
+  await page.screenshot({ path: path.join(SHOTS, 'inkwell-photos-dense.png') });
+
+  // Persistence across reload
+  await page.reload({ waitUntil: 'networkidle' });
+  await page.waitForSelector('.p24-card');
+  const persisted = await page.evaluate(() => ({
+    type: document.querySelector('#p24-filter button.active')?.dataset.type,
+    sort: document.getElementById('p24-sort').value,
+    density: document.getElementById('p24-density').value,
+    cols: getComputedStyle(document.getElementById('p24-grid')).gridTemplateColumns.split(' ').length,
+    cards: document.querySelectorAll('.p24-card').length,
+  }));
+  check('filter/sort/density persist across reload', persisted.type === 'photo' && persisted.sort === 'title-az' && persisted.density === '160' && persisted.cols === cols160 && persisted.cards === p24.counts.photo, JSON.stringify(persisted));
+  await page.$eval('#p24-density', el => { el.value = '400'; el.dispatchEvent(new Event('input', { bubbles: true })); });
+  const cols400 = await page.$eval('#p24-grid', g => getComputedStyle(g).gridTemplateColumns.split(' ').length);
+  check('density slider changes column count', cols160 > cols400, `${cols160} cols @160 -> ${cols400} cols @400`);
+
+  // Modal (a cartoon with panels)
+  await page.click('#p24-filter button[data-type="all"]');
+  await page.locator('.p24-card.cartoon').filter({ has: page.locator('.panels') }).first().click();
+  await page.waitForSelector('.modal .p24-full');
+  await page.waitForTimeout(500);
+  await page.screenshot({ path: path.join(SHOTS, 'inkwell-modal.png') });
+  await page.keyboard.press('Escape');
+
+  // Name-free page text (whole document, incl. research section + bio modal)
+  const text = await page.evaluate(() => document.documentElement.innerText + ' ' + document.title);
+  check('page text contains no name', !NAME.test(text));
+  await page.click('#section-links button[data-section="bio"]');
+  const bioText = await page.$eval('#modal-body', el => el.innerText);
+  check('bio modal contains no name', !NAME.test(bioText), bioText.split('\n')[1]);
+  check('0 console errors', errors.length === 0, errors.slice(0, 3).join(' | '));
+
+  // Reset persisted prefs so the running app opens on defaults
+  await page.evaluate(() => Object.keys(localStorage).filter(k => k.startsWith('inkwell:')).forEach(k => localStorage.removeItem(k)));
+  await browser.close();
+  const failed = results.filter(r => !r.ok);
+  console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
+  process.exit(failed.length ? 1 : 0);
+})().catch(e => { console.error('FAIL  verifier crashed:', e); process.exit(2); });

← ab51ad1 TK-12230: mask the forged artist signature the image model p  ·  back to Paul Conrad Cartoons Shadowman  ·  Replace text-only research-records section with public-domai 5891a07 →