Monorepo for Aesthetic.Computer
aesthetic.computer
1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>jeffrey's instagram · platter</title>
7<meta name="description" content="An index of Jeffrey Alan Scudder's confirmed-self IG selfie corpus from @whistlegraph — 457 photos with face-match scores and per-image GPT-4o descriptions.">
8<link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png">
9<link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css">
10<style>
11:root {
12 color-scheme: dark light;
13 --bg: #111; --bg2: #1a1a1a; --fg: #ddd; --fg2: #888;
14 --accent: #ff6b9d; --accent2: #4ecdc4; --gold: #ffd93d;
15 --border: #333; --hover: #222;
16 --img-bg: #000; --img-placeholder: #222;
17 --lightbox-bg: rgba(0,0,0,0.95);
18}
19@media (prefers-color-scheme: light) {
20 :root {
21 --bg: #fafafa; --bg2: #fff; --fg: #222; --fg2: #666;
22 --accent: #d63384; --accent2: #0a8a82; --gold: #b8860b;
23 --border: #ddd; --hover: #f0f0f0;
24 --img-bg: #eee; --img-placeholder: #ddd;
25 --lightbox-bg: rgba(250,250,250,0.96);
26 }
27}
28* { margin: 0; padding: 0; box-sizing: border-box; }
29html, body { background: var(--bg); color: var(--fg); }
30body {
31 font-family: 'Berkeley Mono Variable', ui-monospace, monospace;
32 font-size: 13px; line-height: 1.4;
33 min-height: 100vh;
34}
35a { color: var(--accent2); text-decoration: none; }
36a:hover { color: var(--accent); text-decoration: underline; }
37
38header {
39 padding: 16px 12px 12px;
40 border-bottom: 1px solid var(--border);
41 position: sticky; top: 0; background: var(--bg); z-index: 10;
42}
43h1 {
44 font-size: 18px; font-weight: normal; letter-spacing: 4px;
45 margin-bottom: 4px;
46}
47h1 .accent { color: var(--accent); }
48.sub { font-size: 11px; color: var(--fg2); margin-bottom: 10px; }
49.controls {
50 display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
51}
52.controls input, .controls select {
53 font-family: inherit; font-size: 12px; padding: 4px 8px;
54 background: var(--bg2); color: var(--fg);
55 border: 1px solid var(--border); outline: none;
56 min-width: 180px;
57}
58.controls input:focus, .controls select:focus { border-color: var(--accent2); }
59.controls .stat {
60 font-size: 11px; color: var(--fg2);
61 margin-left: auto;
62 font-variant-numeric: tabular-nums;
63}
64.controls .stat b { color: var(--fg); font-weight: normal; }
65
66main {
67 padding: 12px;
68 display: grid;
69 grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
70 gap: 10px;
71}
72.card {
73 background: var(--bg2); border: 1px solid var(--border);
74 overflow: hidden; cursor: pointer;
75 display: flex; flex-direction: column;
76 transition: border-color 0.1s, transform 0.1s;
77}
78.card:hover { border-color: var(--accent2); }
79.card:active { transform: scale(0.99); }
80.thumb-wrap {
81 width: 100%; aspect-ratio: 1; background: var(--img-bg);
82 overflow: hidden; position: relative;
83}
84.thumb-wrap img {
85 width: 100%; height: 100%; object-fit: cover; display: block;
86 background: var(--img-placeholder);
87}
88.thumb-wrap .sim {
89 position: absolute; top: 4px; right: 4px;
90 font-size: 10px; padding: 2px 5px;
91 background: rgba(0,0,0,0.7); color: #fff;
92 font-variant-numeric: tabular-nums;
93}
94.meta {
95 padding: 6px 8px; font-size: 11px; color: var(--fg2);
96 display: flex; flex-direction: column; gap: 2px;
97}
98.meta .date { color: var(--fg); font-variant-numeric: tabular-nums; }
99.meta .caption {
100 color: var(--fg2);
101 display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
102 overflow: hidden;
103}
104.meta .tags { color: var(--accent2); font-size: 10px; opacity: 0.8; }
105
106#empty { padding: 40px; text-align: center; color: var(--fg2); }
107#loading { padding: 40px; text-align: center; color: var(--fg2); }
108
109/* lightbox */
110#lightbox {
111 display: none; position: fixed; inset: 0;
112 background: var(--lightbox-bg); z-index: 100;
113 padding: 20px; overflow-y: auto;
114}
115#lightbox.open { display: flex; align-items: flex-start; justify-content: center; }
116.lb-inner {
117 max-width: 1400px; width: 100%;
118 display: grid; grid-template-columns: 1fr; gap: 20px;
119}
120@media (min-width: 800px) {
121 .lb-inner { grid-template-columns: minmax(0, 1fr) 360px; }
122}
123.lb-img {
124 background: var(--img-bg);
125 width: 100%;
126 max-height: calc(100vh - 80px);
127 display: flex; align-items: center; justify-content: center;
128 overflow: hidden;
129}
130.lb-img img {
131 max-width: 100%; max-height: calc(100vh - 80px);
132 width: auto; height: auto;
133 object-fit: contain;
134 transition: filter 0.2s;
135}
136.lb-info {
137 font-size: 13px; line-height: 1.5;
138 display: flex; flex-direction: column; gap: 10px;
139}
140.lb-info h2 {
141 font-size: 14px; font-weight: normal; letter-spacing: 2px;
142 color: var(--accent); margin-bottom: 4px;
143}
144.lb-row {
145 display: grid; grid-template-columns: 110px 1fr; gap: 8px;
146 font-size: 12px;
147}
148.lb-row .k { color: var(--fg2); text-transform: uppercase; font-size: 10px; letter-spacing: 1px; padding-top: 1px; }
149.lb-row .v { color: var(--fg); }
150.lb-row .v.tag-list { color: var(--accent2); }
151.lb-close {
152 position: fixed; top: 12px; right: 16px;
153 font-family: inherit; background: none; border: 1px solid var(--border);
154 color: var(--fg); padding: 4px 10px; cursor: pointer;
155}
156.lb-close:hover { border-color: var(--accent); color: var(--accent); }
157.lb-ig-link {
158 font-size: 11px; color: var(--accent2); margin-top: 4px;
159}
160
161footer {
162 padding: 20px 12px; text-align: center; font-size: 11px;
163 color: var(--fg2); border-top: 1px solid var(--border);
164 margin-top: 20px;
165}
166footer a { color: var(--fg2); }
167footer a:hover { color: var(--accent2); }
168</style>
169</head>
170<body>
171
172<header>
173 <h1><a href="../" style="color:inherit;text-decoration:none;">platter</a><span class="accent">·</span>jeffrey's instagram</h1>
174 <div class="sub">
175 confirmed-self selfie corpus from
176 <a href="https://www.instagram.com/whistlegraph/" target="_blank">@whistlegraph</a>,
177 matched against the AV studio shoot, described per-image by GPT-4o
178 </div>
179 <div class="controls">
180 <input id="search" type="search" placeholder="search descriptions, tags, locations…">
181 <select id="sort">
182 <option value="date-desc">newest first</option>
183 <option value="date-asc">oldest first</option>
184 <option value="sim-desc">highest face-match</option>
185 </select>
186 <select id="domain">
187 <option value="">all domains</option>
188 </select>
189 <span class="stat" id="stat"></span>
190 </div>
191</header>
192
193<main id="grid">
194 <div id="loading">loading manifest…</div>
195</main>
196
197<div id="lightbox">
198 <button class="lb-close" id="lbClose">close ✕</button>
199 <div class="lb-inner" id="lbBody"></div>
200</div>
201
202<footer>
203 <a href="../">all platters</a>
204 ·
205 <a href="https://github.com/digitpain/aesthetic-computer/blob/main/papers/jeffrey-platter/README.md">jeffrey-platter README</a>
206 ·
207 <a href="../../">papers.aesthetic.computer</a>
208</footer>
209
210<script type="module">
211const $ = (id) => document.getElementById(id);
212const grid = $("grid");
213const search = $("search");
214const sortSel = $("sort");
215const domainSel = $("domain");
216const stat = $("stat");
217const lightbox = $("lightbox");
218const lbBody = $("lbBody");
219
220let rows = [];
221
222(async () => {
223 try {
224 const res = await fetch("./manifest.json", { cache: "no-store" });
225 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
226 const data = await res.json();
227 rows = data.rows;
228 populateDomains(rows);
229 render();
230 } catch (e) {
231 grid.innerHTML = `<div id="empty">failed to load manifest: ${e.message}</div>`;
232 }
233})();
234
235function populateDomains(rs) {
236 const domains = [...new Set(rs.map((r) => r.domain).filter(Boolean))].sort();
237 for (const d of domains) {
238 const opt = document.createElement("option");
239 opt.value = d; opt.textContent = d;
240 domainSel.appendChild(opt);
241 }
242}
243
244function filterAndSort() {
245 const q = search.value.trim().toLowerCase();
246 const dom = domainSel.value;
247 let out = rows;
248 if (dom) out = out.filter((r) => r.domain === dom);
249 if (q) {
250 out = out.filter((r) => {
251 const hay = [
252 r.description, r.expression, r.pose, r.location, r.caption,
253 r.style, r.framing, r.domain,
254 ...(r.tags || []),
255 ].filter(Boolean).join(" ").toLowerCase();
256 return hay.includes(q);
257 });
258 }
259 const mode = sortSel.value;
260 if (mode === "date-asc") out = [...out].sort((a, b) => a.date.localeCompare(b.date));
261 else if (mode === "sim-desc") out = [...out].sort((a, b) => b.similarity - a.similarity);
262 else out = [...out].sort((a, b) => b.date.localeCompare(a.date));
263 return out;
264}
265
266function render() {
267 const list = filterAndSort();
268 stat.innerHTML = `<b>${list.length}</b> / ${rows.length}`;
269 if (list.length === 0) {
270 grid.innerHTML = `<div id="empty">no matches</div>`;
271 return;
272 }
273 const html = list.map((r, i) => `
274 <div class="card" data-i="${rows.indexOf(r)}">
275 <div class="thumb-wrap">
276 <img src="${r.full || r.thumb}" loading="lazy" decoding="async" alt="">
277 <div class="sim">${(r.similarity).toFixed(2)}</div>
278 </div>
279 <div class="meta">
280 <div class="date">${r.date}</div>
281 ${r.caption ? `<div class="caption">${escapeHtml(r.caption)}</div>` : ""}
282 ${r.tags?.length ? `<div class="tags">${escapeHtml(r.tags.slice(0, 4).join(" · "))}</div>` : ""}
283 </div>
284 </div>`).join("");
285 grid.innerHTML = html;
286 for (const card of grid.querySelectorAll(".card")) {
287 card.addEventListener("click", () => openLightbox(rows[+card.dataset.i]));
288 }
289}
290
291function openLightbox(r) {
292 const igUrl = `https://www.instagram.com/p/${r.shortcode}/`;
293 // Same full-res image the grid uses — the browser already has it cached
294 // from the card click, so the lightbox renders instantly.
295 const hires = r.full || r.thumb;
296 lbBody.innerHTML = `
297 <div class="lb-img"><img src="${hires}" alt=""></div>
298 <div class="lb-info">
299 <h2>${r.date}</h2>
300 <div class="lb-row"><span class="k">subject</span><span class="v">${escapeHtml(r.description || "—")}</span></div>
301 <div class="lb-row"><span class="k">expression</span><span class="v">${escapeHtml(r.expression || "—")}</span></div>
302 <div class="lb-row"><span class="k">pose</span><span class="v">${escapeHtml(r.pose || "—")}</span></div>
303 <div class="lb-row"><span class="k">location</span><span class="v">${escapeHtml(r.location || "—")}</span></div>
304 <div class="lb-row"><span class="k">style</span><span class="v">${escapeHtml(r.style || "—")} · ${escapeHtml(r.framing || "—")}</span></div>
305 <div class="lb-row"><span class="k">domain</span><span class="v">${escapeHtml(r.domain || "—")}</span></div>
306 ${r.tags?.length ? `<div class="lb-row"><span class="k">tags</span><span class="v tag-list">${escapeHtml(r.tags.join(" · "))}</span></div>` : ""}
307 ${r.caption ? `<div class="lb-row"><span class="k">caption</span><span class="v">${escapeHtml(r.caption)}</span></div>` : ""}
308 <div class="lb-row"><span class="k">face-match</span><span class="v">${r.similarity.toFixed(4)} (conf ${r.confidence ?? "—"})</span></div>
309 <div class="lb-row"><span class="k">others</span><span class="v">${r.n_other_people} other people in frame</span></div>
310 <a class="lb-ig-link" href="${igUrl}" target="_blank">view on instagram ↗</a>
311 </div>`;
312 lightbox.classList.add("open");
313 document.body.style.overflow = "hidden";
314}
315
316function closeLightbox() {
317 lightbox.classList.remove("open");
318 document.body.style.overflow = "";
319}
320
321$("lbClose").addEventListener("click", closeLightbox);
322lightbox.addEventListener("click", (e) => { if (e.target === lightbox) closeLightbox(); });
323document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeLightbox(); });
324
325let t;
326search.addEventListener("input", () => { clearTimeout(t); t = setTimeout(render, 120); });
327sortSel.addEventListener("change", render);
328domainSel.addEventListener("change", render);
329
330function escapeHtml(s) {
331 return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
332}
333</script>
334
335</body>
336</html>