mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 23:26:39 +03:00
Deal every card the same hand: anatomy, sketches, and the standing door
The decision page compared unlike things: the grounded direction was a wall of text beside curated catalog art, the catalog art read as a promise of the build, the weighing silently shrank the challenger set, and the standing exit hid in the footer under the cards it must not soften. Every card now shares one anatomy (thesis, palette chips, material tags, first viewport, case, risk), every dealt challenger is presented with the weighing written on it rather than applied to it, the catalog image rides picture-in-picture as labeled inspiration with the lightbox a click away, and canonCard renders the category standard as one honest, subordinate card. When image generation exists, each card declares a sketch slot the page polls: serve first, generate after, through one shared deliberately unfinished frame, so the comparison stays about direction instead of rendering luck. The asset producer takes the batch when subagents exist; the chosen sketch returns in ANSWER to seed at most one comp probe, and the comp round still renders its full set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5bec5408e5
commit
d89ee5f87c
@@ -24,21 +24,36 @@
|
||||
* "label": "Fillmore Handbill",
|
||||
* "kicker": "THE ROLL", // optional badge; the assigned option leads
|
||||
* "lineage": "1966-71 Fillmore ...", // optional
|
||||
* "body": "why it fits, first viewport, risk ...", // optional, plain text
|
||||
* "hero": "https://... or /abs/path.webp", // optional image
|
||||
* "thesis": "one line: the idea this direction owns", // optional
|
||||
* "palette": ["#1a2f5e", "oklch(84% .19 80)", ...], // optional, rendered as chips
|
||||
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
|
||||
* "viewport": "one line: the first-viewport composition", // optional
|
||||
* "case": "one line: the fusion verdict, honest", // optional
|
||||
* "risk": "one line: the honest risk", // optional
|
||||
* "body": "fallback prose when the structured fields are absent",
|
||||
* "sketch": ".impeccable/sketches/assigned.webp", // optional; may not exist
|
||||
* // yet: the page shimmer-waits and polls the
|
||||
* // slot until the file lands, so serve first
|
||||
* // and generate after
|
||||
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
|
||||
* // rides picture-in-picture when a sketch exists
|
||||
* "board": "https://... or /abs/path.webp" // optional secondary image
|
||||
* }, ...
|
||||
* ],
|
||||
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
|
||||
* "canon": true, // adds the quiet "Play it straight" standing exit
|
||||
* // (returns {"optionId":"canon"}); direction rounds only
|
||||
* "canon": true, // adds the "Play it straight" standing exit;
|
||||
* // direction rounds only (returns {"optionId":"canon"})
|
||||
* "canonCard": { ... }, // optional: the standing exit as a full card with the
|
||||
* // same anatomy (label, thesis, palette, sketch, ...);
|
||||
* // rendered last and visually subordinate. Without it,
|
||||
* // canon stays a quiet footer action.
|
||||
* "steer": true // adds a free-text steer field returned with any answer
|
||||
* }
|
||||
*
|
||||
* Options render as large cards: hero render first when present (the dealt
|
||||
* catalog worlds already have cards; grounded directions may present text-only
|
||||
* or a freshly generated mock). Local image paths are served by this server;
|
||||
* nothing is uploaded anywhere.
|
||||
* Options render as large cards: the sketch leads when present, with the
|
||||
* inspiration image picture-in-picture; a hero alone renders full-bleed; a
|
||||
* text-only direction gets its identity from the palette chips and tags.
|
||||
* Local image paths are served by this server; nothing is uploaded anywhere.
|
||||
*
|
||||
* Modes:
|
||||
* (default) block until answered; ANSWER on stdout; exit 0.
|
||||
@@ -107,6 +122,9 @@ function printAnswer(raw) {
|
||||
if (a.hero || a.board) {
|
||||
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach.");
|
||||
}
|
||||
if (a.sketch) {
|
||||
console.log('CHOSEN SKETCH: the decision sketch at that path may seed one comp probe; the comp round still renders its full set, because a sketch chose the direction, not the composition.');
|
||||
}
|
||||
if (a.optionId === 'canon') {
|
||||
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
|
||||
}
|
||||
@@ -125,14 +143,15 @@ if (hasFlag('schema')) {
|
||||
title: 'Choose the visual world',
|
||||
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', body: 'Why it fits, the first viewport, the honest risk.', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', body: 'Fused alternate.', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
|
||||
],
|
||||
reroll: true,
|
||||
canon: true,
|
||||
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
|
||||
steer: true,
|
||||
}, null, 2));
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board accept URLs or local paths. canon adds a quiet standing "Play it straight" action for direction decisions: the user\'s explicit door to the category standard. Include it only for visual-direction rounds; never present canon as your own recommendation.');
|
||||
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -244,12 +263,28 @@ function loadRound(json) {
|
||||
localImages.push(abs);
|
||||
return `/img/${localImages.length - 1}`;
|
||||
};
|
||||
// Sketches stream in after the page is served, so their slots register
|
||||
// whether or not the file exists yet; /img answers 404 until it lands and
|
||||
// the page polls the slot. Remote sketch URLs pass through untouched.
|
||||
const sketchSrc = (value) => {
|
||||
if (!value) return null;
|
||||
if (/^https?:\/\//.test(value)) return value;
|
||||
localImages.push(path.resolve(value));
|
||||
return `/img/${localImages.length - 1}`;
|
||||
};
|
||||
payload = parsed;
|
||||
options = parsed.options.map((option) => ({
|
||||
const decorate = (option) => ({
|
||||
...option,
|
||||
heroSrc: imageSrc(option.hero),
|
||||
boardSrc: imageSrc(option.board),
|
||||
}));
|
||||
sketchSrc: sketchSrc(option.sketch),
|
||||
});
|
||||
options = parsed.options.map(decorate);
|
||||
// The standing exit as a full card: same anatomy, reserved id, rendered
|
||||
// subordinate by the page. Without it, canon stays the quiet footer action.
|
||||
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
|
||||
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
|
||||
}
|
||||
}
|
||||
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
|
||||
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
|
||||
@@ -260,20 +295,61 @@ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<
|
||||
function page() {
|
||||
const flipChip = (label) => `<button type="button" class="chip flip" aria-label="Flip the card"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 1-8 8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M4 5.5V12h6.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg><span>${label}</span></button>`;
|
||||
const expandChip = `<button type="button" class="chip expand" aria-label="Expand the image"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 15v5h-5M20 9V4h-5M4 15v5h5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></button>`;
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${option.heroSrc || option.boardSrc ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : ''}
|
||||
${option.heroSrc || option.boardSrc ? `<div class="media">
|
||||
// Structured anatomy: chips and one-line facts render when the payload
|
||||
// carries them; a plain body falls back to the prose block. Palette chips
|
||||
// and material tags give a text-only direction an immediate identity that
|
||||
// no generation luck can distort.
|
||||
const anatomy = (option) => {
|
||||
const rows = [];
|
||||
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
|
||||
const idBits = [];
|
||||
if (Array.isArray(option.palette) && option.palette.length) {
|
||||
idBits.push(`<span class="swatches">${option.palette.slice(0, 6).map((c) => `<i style="background:${esc(c)}" title="${esc(c)}"></i>`).join('')}</span>`);
|
||||
}
|
||||
if (Array.isArray(option.materials) && option.materials.length) {
|
||||
idBits.push(option.materials.slice(0, 4).map((m) => `<span class="tag">${esc(m)}</span>`).join(''));
|
||||
}
|
||||
if (idBits.length) rows.push(`<div class="identity">${idBits.join('')}</div>`);
|
||||
const fact = (label, value) => value ? `<p class="fact"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
|
||||
rows.push(fact('First viewport', option.viewport));
|
||||
rows.push(fact('The case', option.case));
|
||||
rows.push(fact('Risk', option.risk));
|
||||
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
|
||||
else if (option.body && option.thesis) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
|
||||
return rows.join('\n ');
|
||||
};
|
||||
const media = (option) => {
|
||||
const inspiration = option.heroSrc ? `<figure class="pip" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
|
||||
<img src="${esc(option.heroSrc)}" alt="">
|
||||
<figcaption>inspiration</figcaption>
|
||||
</figure>` : '';
|
||||
if (option.sketchSrc) {
|
||||
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
|
||||
<div class="shimmer"><span class="sketch-note">sketching…</span></div>
|
||||
<img class="sketch" alt="" hidden>
|
||||
${inspiration}
|
||||
<div class="chips">${expandChip}${option.boardSrc ? flipChip('Board') : ''}</div>
|
||||
</div>`;
|
||||
}
|
||||
if (option.heroSrc || option.boardSrc) {
|
||||
return `<div class="media">
|
||||
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
|
||||
<div class="chips">${expandChip}${option.boardSrc && option.heroSrc ? flipChip('Board') : ''}</div>
|
||||
</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const cards = options.map((option, index) => `
|
||||
<article class="card${option.isCanon ? ' canon' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
|
||||
<div class="card-inner">
|
||||
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
|
||||
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
|
||||
${media(option)}
|
||||
<div class="body">
|
||||
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
|
||||
<h2>${esc(option.label)}</h2>
|
||||
${option.body ? `<p class="detail">${esc(option.body)}</p>` : ''}
|
||||
<button class="choose" data-id="${esc(option.id)}">Build this</button>
|
||||
${anatomy(option)}
|
||||
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
|
||||
</div>
|
||||
</div>
|
||||
${option.boardSrc && option.heroSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
|
||||
@@ -367,6 +443,31 @@ function page() {
|
||||
.tier { font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; line-height: 1.35; color: var(--ks-champagne); }
|
||||
.detail { color: var(--ks-text-muted); font-size: .88rem; white-space: pre-wrap; }
|
||||
.detail.more { font-size: .8rem; color: var(--ks-text-faint); }
|
||||
.thesis { color: var(--ks-text); font-size: .95rem; line-height: 1.45; }
|
||||
.identity { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 2px 0; }
|
||||
.swatches { display: inline-flex; gap: 4px; margin-right: 4px; }
|
||||
.swatches i { width: 18px; height: 18px; border-radius: 5px; border: 1px solid oklch(100% 0 0 / 0.18); box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.25); }
|
||||
.tag { font-family: var(--ks-mono); font-size: .6rem; letter-spacing: .14em; text-transform: uppercase; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); border-radius: 4px; padding: 3px 7px; }
|
||||
.fact { font-size: .8rem; color: var(--ks-text-muted); line-height: 1.45; }
|
||||
.fact-label { display: inline-block; font-family: var(--ks-mono); font-size: .6rem; letter-spacing: .18em; text-transform: uppercase; color: var(--ks-text-faint); margin-right: .55em; transform: translateY(-1px); }
|
||||
/* Inspiration rides picture-in-picture: the catalog world explains where the
|
||||
direction comes from without promising what the build will look like. */
|
||||
.pip { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; width: 74px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; background: var(--ks-lacquer); cursor: zoom-in; transform-origin: bottom left; transition: transform .3s cubic-bezier(.16,1,.3,1), box-shadow .3s; box-shadow: 0 6px 18px oklch(0% 0 0 / 0.45); }
|
||||
.pip img { display: block; width: 100%; aspect-ratio: 16/10; object-fit: cover; }
|
||||
.pip figcaption { font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text-faint); text-align: center; padding: 3px 0 4px; }
|
||||
.pip:hover { transform: scale(2.6); box-shadow: 0 14px 40px oklch(0% 0 0 / 0.6); z-index: 3; }
|
||||
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
|
||||
.media.sketching { position: relative; }
|
||||
.media.sketching .shimmer { position: absolute; inset: 0; }
|
||||
.media img.sketch { position: relative; z-index: 1; }
|
||||
/* The standing exit as a card: present with full anatomy, never dressed as a
|
||||
contender. Graphite instead of kinpaku, and it never takes the lead ring. */
|
||||
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
|
||||
.card.canon:hover .face { border-color: var(--ks-text-faint); }
|
||||
.card.canon .kicker.standing { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
|
||||
.card.canon button.choose { background: transparent; color: var(--ks-text); border: 1px solid var(--ks-rule); }
|
||||
.card.canon button.choose:hover { border-color: var(--ks-text-muted); background: var(--ks-graphite-2); }
|
||||
button.choose { margin-top: auto; align-self: start; background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font-family: var(--ks-font); font-size: 1rem; font-weight: 500; line-height: 1.35; padding: 10px 38px; border-radius: 6px; cursor: pointer; transition: background .15s; }
|
||||
button.choose:hover { background: var(--ks-kinpaku-pale); }
|
||||
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
||||
@@ -414,7 +515,7 @@ function page() {
|
||||
<footer>
|
||||
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
|
||||
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
|
||||
${payload.canon ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||
${payload.canon && !payload.canonCard ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
|
||||
</footer>
|
||||
<script>
|
||||
const steer = () => document.getElementById('steer')?.value || '';
|
||||
@@ -460,12 +561,50 @@ function page() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Ambient: the hovered card's hero bleeds into the page ground under a scrim.
|
||||
// Sketches stream in after the deal: poll each slot until the file lands,
|
||||
// then swap the shimmer for the image. On a long timeout, promote the
|
||||
// inspiration to full bleed when there is one, otherwise fold to text-only.
|
||||
document.querySelectorAll('.media.sketching').forEach(m => {
|
||||
const url = m.dataset.sketch;
|
||||
const img = m.querySelector('img.sketch');
|
||||
const started = Date.now();
|
||||
const settle = () => { m.classList.remove('sketching'); m.querySelector('.shimmer')?.remove(); };
|
||||
const tryLoad = () => {
|
||||
const probe = new Image();
|
||||
probe.onload = () => { img.src = probe.src; img.hidden = false; settle(); };
|
||||
probe.onerror = () => {
|
||||
if (Date.now() - started > 300000) {
|
||||
const pip = m.querySelector('.pip img');
|
||||
if (pip) { img.src = pip.getAttribute('src'); img.hidden = false; }
|
||||
else { m.closest('.face').classList.add('text-only'); m.remove(); }
|
||||
settle();
|
||||
return;
|
||||
}
|
||||
setTimeout(tryLoad, 2500);
|
||||
};
|
||||
probe.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
};
|
||||
tryLoad();
|
||||
});
|
||||
|
||||
// Inspiration PIP opens the full catalog card in the lightbox.
|
||||
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const img = p.querySelector('img');
|
||||
if (!img) return;
|
||||
lightboxImg.src = img.getAttribute('src');
|
||||
lightbox.hidden = false;
|
||||
requestAnimationFrame(() => lightbox.classList.add('open'));
|
||||
}));
|
||||
|
||||
// Ambient: the hovered card's visible art bleeds into the page ground.
|
||||
const ambient = document.getElementById('ambient');
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
const hero = card.querySelector('.face.front .media img');
|
||||
if (!hero) return;
|
||||
card.addEventListener('mouseenter', () => { ambient.style.backgroundImage = 'url("' + hero.getAttribute('src') + '")'; ambient.style.opacity = '1'; });
|
||||
card.addEventListener('mouseenter', () => {
|
||||
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img');
|
||||
if (!art || !art.getAttribute('src')) return;
|
||||
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
|
||||
});
|
||||
card.addEventListener('mouseleave', () => { ambient.style.opacity = '0'; });
|
||||
});
|
||||
|
||||
@@ -476,8 +615,8 @@ function page() {
|
||||
e.stopPropagation();
|
||||
const card = b.closest('.card');
|
||||
const face = card.classList.contains('flipped') ? '.face.back' : '.face.front';
|
||||
const img = card.querySelector(face + ' .media img');
|
||||
if (!img) return;
|
||||
const img = card.querySelector(face + ' .media img:not([hidden])');
|
||||
if (!img || !img.getAttribute('src')) return;
|
||||
lightboxImg.src = img.getAttribute('src');
|
||||
lightbox.hidden = false;
|
||||
requestAnimationFrame(() => lightbox.classList.add('open'));
|
||||
@@ -549,7 +688,7 @@ const server = http.createServer((req, res) => {
|
||||
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)$/);
|
||||
if (imageMatch) {
|
||||
const abs = localImages[Number(imageMatch[1])];
|
||||
if (!abs) { res.writeHead(404); res.end(); return; }
|
||||
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
|
||||
const type = abs.endsWith('.webp') ? 'image/webp'
|
||||
: abs.endsWith('.png') ? 'image/png'
|
||||
: abs.endsWith('.svg') ? 'image/svg+xml'
|
||||
@@ -572,6 +711,7 @@ const server = http.createServer((req, res) => {
|
||||
optionId: parsed.optionId ?? null,
|
||||
steer: parsed.steer ?? '',
|
||||
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
|
||||
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
|
||||
});
|
||||
const isReroll = parsed.optionId === 'reroll';
|
||||
if (detachedKey) {
|
||||
|
||||
Reference in New Issue
Block a user