diff --git a/system/backend/mime-media.mjs b/system/backend/mime-media.mjs
index aafed5ce2c..9a37e3308d 100644
--- a/system/backend/mime-media.mjs
+++ b/system/backend/mime-media.mjs
@@ -7,6 +7,7 @@ export const MEDIA_KINDS = {
tape: "tapes",
piece: "pieces",
kidlisp: "kidlisp",
+ whistlegraph: "whistlegraph-mints",
};
export const MEDIA_THREADS = "mime-media-threads";
@@ -22,10 +23,30 @@ const visible = {
const nonempty = { $type: "string", $ne: "" };
const literal = (value) => ({ $literal: value });
const pieceExtension = { $toLower: { $ifNull: ["$extension", { $ifNull: ["$ext", "mjs"] }] } };
+const ipfsPattern = /^ipfs:\/\/Qm[1-9A-HJ-NP-Za-km-z]{44}$/;
+const origins = {
+ painting: { name: "Aesthetic Computer", url: "https://aesthetic.computer/" },
+ tape: { name: "Aesthetic Computer", url: "https://aesthetic.computer/" },
+ piece: { name: "Aesthetic Computer", url: "https://aesthetic.computer/" },
+ kidlisp: { name: "KidLisp", url: "https://kidlisp.com/" },
+ whistlegraph: { name: "Whistlegraph", url: "https://whistlegraph.app/" },
+};
+
+function sourceVisibility(kind) {
+ if (kind === "whistlegraph") return { ...visible, status: "minted",
+ artifactUri: { $regex: ipfsPattern }, tokenId: { $type: "string", $regex: /^\d+$/ } };
+ return { ...visible, ...(kind === "kidlisp" ? { source: nonempty } : { slug: nonempty }) };
+}
+
+export function packedMediaUrl(uri) {
+ if (typeof uri !== "string" || !ipfsPattern.test(uri)) throw new Error("Invalid packed artwork URI");
+ return "https://ipfs.aesthetic.computer/ipfs/" + uri.slice(7);
+}
export function mediaPipeline(kind, match = {}, activity = true) {
if (!Object.hasOwn(MEDIA_KINDS, kind)) throw new Error("Unknown media kind");
- const type = kind === "painting" ? literal("image/png")
+ const packed = kind === "whistlegraph";
+ const type = packed ? literal("text/html") : kind === "painting" ? literal("image/png")
: kind === "kidlisp" ? literal("text/x-lisp")
: kind === "tape" ? { $cond: [
{ $and: [{ $eq: ["$mp4Status", "complete"] }, { $ne: [{ $ifNull: ["$mp4Url", ""] }, ""] }] },
@@ -38,17 +59,19 @@ export function mediaPipeline(kind, match = {}, activity = true) {
], default: "text/javascript",
} };
const pipeline = [
- { $match: { ...visible, ...(kind === "kidlisp" ? { source: nonempty } : { slug: nonempty }), ...match } },
+ { $match: { ...match, ...sourceVisibility(kind) } },
{ $project: {
_id: 0,
code: { $concat: [kind + "_", { $toString: "$_id" }] },
parent: literal(null), board: type,
- when: { $ifNull: ["$when", { $toDate: "$_id" }] },
+ when: packed ? "$mintedAt" : { $ifNull: ["$when", { $toDate: "$_id" }] },
text: literal(""),
_media: {
kind: literal(kind), id: { $toString: "$_id" }, code: "$code",
- user: "$user", slug: "$slug", size: "$size",
+ user: "$user", slug: "$slug", size: packed ? "$bytes" : "$size",
hasPoster: { $ne: [{ $ifNull: ["$thumbnailUrl", ""] }, ""] },
+ ...(packed ? { title: "$title", version: "$version", aspect: "$aspect",
+ artifactUri: "$artifactUri", coverUri: "$coverUri", tokenId: "$tokenId" } : {}),
},
} },
];
@@ -66,6 +89,8 @@ export function mediaPipeline(kind, match = {}, activity = true) {
export function parseMediaThread(code) {
if (typeof code !== "string") return null;
+ const pack = /^whistlegraph_([a-f0-9]{64})$/.exec(code);
+ if (pack) return { kind: "whistlegraph", id: pack[1] };
const match = /^(painting|tape|piece|kidlisp)_([a-f0-9]{24})$/.exec(code);
return match ? { kind: match[1], id: new ObjectId(match[2]) } : null;
}
@@ -79,14 +104,14 @@ export async function resolveMedia(db, kind, ref) {
if (typeof ref !== "string" || !ref) return null;
// Prefer a public short code. An ObjectId is the fallback for legacy records.
return await mediaThread(db, kind, { code: ref }) ||
- (/^[a-f0-9]{24}$/.test(ref) ? mediaThread(db, kind, { _id: new ObjectId(ref) }) : null);
+ (kind === "whistlegraph" && /^[a-f0-9]{64}$/.test(ref) ? mediaThread(db, kind, { _id: ref }) :
+ /^[a-f0-9]{24}$/.test(ref) ? mediaThread(db, kind, { _id: new ObjectId(ref) }) : null);
}
export async function sourceRecord(db, code) {
const ref = parseMediaThread(code);
if (!ref) return null;
- return db.collection(MEDIA_KINDS[ref.kind]).findOne({ ...visible,
- ...(ref.kind === "kidlisp" ? { source: nonempty } : { slug: nonempty }), _id: ref.id });
+ return db.collection(MEDIA_KINDS[ref.kind]).findOne({ ...sourceVisibility(ref.kind), _id: ref.id });
}
export async function publicPosts(db, docs) {
@@ -101,16 +126,18 @@ export async function publicPosts(db, docs) {
const extension = {
"image/png": "png", "video/mp4": "mp4", "application/zip": "zip",
"text/javascript": "mjs", "text/x-lisp": "lisp", "text/x-lua": "lua",
+ "text/html": "html",
}[doc.board];
const url = `/api/mime?file=${encodeURIComponent(doc.code)}`;
const file = media
- ? { name: `${media.code || media.id}.${extension}`, type: doc.board, size: media.size ?? null, url,
- ...(media.kind === "tape" && media.hasPoster ? { poster: `/api/mime?poster=${encodeURIComponent(doc.code)}` } : {}) }
+ ? { name: `${media.code || media.id}${media.kind === "whistlegraph" ? "-v" + media.version : ""}.${extension}`, type: doc.board, size: media.size ?? null, url,
+ ...((media.kind === "tape" && media.hasPoster || media.kind === "whistlegraph" && ipfsPattern.test(media.coverUri || "")) ? { poster: `/api/mime?poster=${encodeURIComponent(doc.code)}` } : {}) }
: doc.file ? { name: doc.file.name, type: doc.file.type, size: doc.file.size, url } : null;
let original;
if (media?.code) {
const code = encodeURIComponent(media.code);
- original = media.kind === "painting" ? `https://aesthetic.computer/#${code}`
+ original = media.kind === "whistlegraph" ? packedMediaUrl(media.artifactUri)
+ : media.kind === "painting" ? `https://aesthetic.computer/#${code}`
: media.kind === "tape" ? `https://aesthetic.computer/!${code}`
: media.kind === "kidlisp" ? `https://aesthetic.computer/$${code}`
: `https://aesthetic.computer/${code}`;
@@ -119,9 +146,12 @@ export async function publicPosts(db, docs) {
code: doc.code, parent: doc.parent, board: doc.board,
name: media || doc.user ? handle : doc.name, text: doc.text, when: doc.when,
replies: doc.replies || 0, file,
+ origin: origins[media?.kind] || { name: "Mime", url: "https://mime.ac/" },
...(media ? { media: {
kind: media.kind, id: media.id, code: media.code || null,
url: original || url, author: handle,
+ ...(media.kind === "whistlegraph" ? { title: media.title, version: media.version,
+ aspect: media.aspect, packed: true, tokenUrl: `https://teia.art/objkt/${media.tokenId}` } : {}),
} } : {}),
};
});
@@ -130,6 +160,7 @@ export async function publicPosts(db, docs) {
// Resolve storage only from an allowlisted, visible media record. Request
// bodies cannot supply external URLs or choose arbitrary collections/buckets.
export function mediaFile(kind, record) {
+ if (kind === "whistlegraph") return { url: packedMediaUrl(record.artifactUri) };
if ((kind === "kidlisp" || kind === "piece") && typeof record.source === "string") {
return { text: record.source };
}
diff --git a/system/backend/tests/mime.test.mjs b/system/backend/tests/mime.test.mjs
index fc4c5980dc..8937637a39 100644
--- a/system/backend/tests/mime.test.mjs
+++ b/system/backend/tests/mime.test.mjs
@@ -13,6 +13,10 @@ if (uri && !/^mongodb:\/\/(127\.0\.0\.1|localhost|\[::1\])[:/]/.test(uri)) {
let client, db, handler;
const paintingId = new ObjectId();
const paintingThread = `painting_${paintingId}`;
+const packId = "a".repeat(64);
+const packThread = `whistlegraph_${packId}`;
+const artifactUri = "ipfs://QmfM7Jc8vdju2qCVNJ8ZZc2RQ9ma3QyEjYGXHb9xR5RPfp";
+const coverUri = "ipfs://QmbU6bEoXPXGr42FkoazpfQ4xx1r96MnHsXHTACByfV7S5";
const when = new Date("2026-09-01T12:00:00Z");
const fixture = (code, extra = {}) => ({ code, slug: code, when, ...extra });
const file = (name, type = "image/png") => ({ name, type, data: Buffer.from("fixture").toString("base64") });
@@ -43,6 +47,17 @@ before(async () => {
]);
await db.collection("kidlisp").insertOne(fixture("lisp", { source: "(wipe red)", user: "auth0|fixture" }));
await db.collection("tape-drafts").insertOne(fixture("never-published"));
+ const pack = { _id: packId, code: "wgDefen", version: 7, status: "minted", mintedAt: when,
+ user: "auth0|fixture", title: "Spinning tree", aspect: "2:3", bytes: 642922,
+ artifactUri, coverUri, tokenId: "885469", nonce: "secret-nonce", signature: "secret-signature",
+ source: "private-source", threadId: "private-thread" };
+ await db.collection("whistlegraph-mints").insertMany([
+ pack,
+ ...["packing", "packed", "ready", "requested", "failed"].map((status, i) => ({ ...pack, _id: String(i).repeat(64), status })),
+ { ...pack, _id: "b".repeat(64), private: true },
+ { ...pack, _id: "c".repeat(64), visibility: "unlisted" },
+ { ...pack, _id: "d".repeat(64), artifactUri: "https://evil.example/art.html" },
+ ]);
});
after(async () => { if (db) await db.dropDatabase(); if (client) await client.close(); });
@@ -61,10 +76,10 @@ test("public media automatically become attributed posts without writes", { skip
const response = await get();
assert.equal(response.statusCode, 200);
const index = body(response);
- assert.equal(index.recent.length, 8);
+ assert.equal(index.recent.length, 9);
assert.equal(index.boards.find((b) => b.board === "image/png").threads, 3);
assert.deepEqual(new Set(index.boards.map((b) => b.board)), new Set([
- "image/png", "video/mp4", "application/zip", "text/javascript", "text/x-lua", "text/x-lisp",
+ "image/png", "video/mp4", "application/zip", "text/javascript", "text/x-lua", "text/x-lisp", "text/html",
]));
const painting = index.recent.find((p) => p.code === paintingThread);
assert.equal(painting.name, "@artist");
@@ -78,6 +93,36 @@ test("public media automatically become attributed posts without writes", { skip
assert.deepEqual(resolved.replies, []);
});
+test("Whistlegraph publishes only confirmed packs, with provenance and no mint secrets", { skip: !uri }, async () => {
+ const response = await get();
+ const packs = body(response).recent.filter(p => p.media?.kind === "whistlegraph");
+ assert.equal(packs.length, 1);
+ const [pack] = packs;
+ assert.equal(pack.code, packThread);
+ assert.equal(pack.name, "@artist");
+ assert.equal(pack.when, when.toISOString());
+ assert.deepEqual(pack.origin, { name: "Whistlegraph", url: "https://whistlegraph.app/" });
+ assert.equal(pack.media.title, "Spinning tree");
+ assert.equal(pack.media.aspect, "2:3");
+ assert.equal(pack.media.packed, true);
+ assert.equal(pack.media.tokenUrl, "https://teia.art/objkt/885469");
+ assert.equal(pack.file.name, "wgDefen-v7.html");
+ assert.equal(pack.file.type, "text/html");
+ assert.equal(pack.file.size, 642922);
+ assert.doesNotMatch(response.body, /secret-|private-|nonce|signature|threadId|auth0/);
+ for (const [key, uri] of [["file", artifactUri], ["poster", coverUri]]) {
+ const result = await get({ [key]: packThread });
+ assert.equal(result.statusCode, 302);
+ assert.equal(result.headers.Location, uri.replace("ipfs://", "https://ipfs.aesthetic.computer/ipfs/"));
+ }
+ assert.equal(body(await get({ media: "whistlegraph", code: packId })).op.code, packThread);
+ await db.collection("whistlegraph-mints").updateOne({ _id: packId }, { $set: { hidden: true } });
+ for (const key of ["thread", "file", "poster"]) assert.equal((await get({ [key]: packThread })).statusCode, 404);
+ assert.equal((await post({ parent: packThread, text: "hidden" })).statusCode, 404);
+ assert.ok(!body(await get()).recent.some(p => p.code === packThread));
+ await db.collection("whistlegraph-mints").updateOne({ _id: packId }, { $unset: { hidden: "" } });
+});
+
test("simultaneous first replies share one thread, with current source metadata", { skip: !uri }, async () => {
const responses = await Promise.all(Array.from({ length: 8 }, (_, i) => post({ parent: paintingThread, text: `reply ${i}` })));
for (const r of responses) assert.equal(r.statusCode, 200, r.body);
@@ -201,7 +246,7 @@ test("archive pagination is stable and new uploads appear without a sync job", {
assert.equal(new Set([...first.threads, ...second.threads].map((t) => t.op.code)).size, 24);
const recent = body(await get());
assert.equal(recent.hasMore, true);
- for (const kind of ["painting", "tape", "kidlisp", "piece"]) {
+ for (const kind of ["whistlegraph", "painting", "tape", "kidlisp", "piece"]) {
assert.ok(recent.recent.some((p) => p.media?.kind === kind), kind + " stays visible during a painting burst");
}
const next = body(await get({ page: "1" }));
diff --git a/system/netlify/functions/mime.mjs b/system/netlify/functions/mime.mjs
index a80edf07ad..7425a82ced 100644
--- a/system/netlify/functions/mime.mjs
+++ b/system/netlify/functions/mime.mjs
@@ -5,7 +5,7 @@ import { respond as httpRespond } from "../../backend/http.mjs";
import { generateUniqueCode } from "../../backend/generate-short-code.mjs";
import {
MEDIA_KINDS, MEDIA_THREADS, mediaPipeline, parseMediaThread,
- mediaThread, resolveMedia, sourceRecord, publicPosts, mediaFile, publicMediaUrl,
+ mediaThread, resolveMedia, sourceRecord, publicPosts, mediaFile, publicMediaUrl, packedMediaUrl,
} from "../../backend/mime-media.mjs";
const respond = (status, body, headers = {}) => httpRespond(status, body, { "Cache-Control": "no-store", ...headers });
@@ -127,8 +127,11 @@ async function serveFile(db, posts, code) {
async function servePoster(db, code) {
const ref = parseMediaThread(code);
- if (ref?.kind !== "tape") return respond(404, { error: "poster not found" });
+ if (!["tape", "whistlegraph"].includes(ref?.kind)) return respond(404, { error: "poster not found" });
const record = await sourceRecord(db, code);
+ if (ref.kind === "whistlegraph") return record?.coverUri
+ ? respond(302, "", { Location: packedMediaUrl(record.coverUri), "X-Content-Type-Options": "nosniff" })
+ : respond(404, { error: "poster not found" });
if (!record?.thumbnailUrl) return respond(404, { error: "poster not found" });
return respond(302, "", { Location: publicMediaUrl(record.thumbnailUrl), "X-Content-Type-Options": "nosniff" });
}
@@ -155,7 +158,7 @@ async function getIndex(db, posts, page, feedOnly = false) {
{ $sort: { bumped: -1, _id: 1 } },
]).toArray(),
Promise.all([
- ...["painting", "tape", "kidlisp", "piece"].map((kind) =>
+ ...["whistlegraph", "painting", "tape", "kidlisp", "piece"].map((kind) =>
db.collection(MEDIA_KINDS[kind]).aggregate([...mediaPipeline(kind, {}, false), ...order]).toArray()),
posts.aggregate([{ $match: { parent: null } }, { $project: { "file.data": 0 } }, ...order]).toArray(),
]),
diff --git a/system/public/mime/index.html b/system/public/mime/index.html
index a15b6d5666..4d74665258 100644
--- a/system/public/mime/index.html
+++ b/system/public/mime/index.html
@@ -129,12 +129,15 @@
.avatar { background: var(--author); color: var(--card); border-color: var(--author); }
.byline { min-width: 0; flex: 1; }
.name { font-weight: 650; color: var(--author); font-size: 16px; text-decoration: none; }
- .post time { white-space: nowrap; font-size: 11px; }
- .media-label { display: block; color: var(--dim); font-size: 12px; text-decoration: none; }
+ .post time { white-space: nowrap; font-size: 12px; }
+ .media-label { display: block; color: var(--ink); font-size: 14px; text-decoration: none; }
+ .media-origin { display: block; color: var(--dim); font-size: 12px; text-decoration: none; }
.feed-media { width: 100%; aspect-ratio: 4 / 3; display: grid; place-items: center;
grid-template: minmax(0, 1fr) / minmax(0, 1fr); overflow: hidden; background: var(--media);
border-block: 1px solid var(--card-edge); }
.feed-media.media-audio { aspect-ratio: auto; min-height: 112px; }
+ .feed-media.media-pack { aspect-ratio: var(--media-ratio, 2 / 3);
+ width: min(100%, calc(min(55svh, 600px) * var(--pack-ratio, .666667))); margin-inline: auto; }
.feed-media.media-image, .feed-media.media-video { aspect-ratio: var(--media-ratio, 4 / 3); }
.feed-media.media-image > img, .feed-media.media-video > video { object-fit: contain; }
.media-loading { position: absolute; z-index: 1; left: 50%; top: 50%; transform: translate(-50%, -50%);
@@ -154,7 +157,7 @@
.save-ribbon svg { width: 20px; height: 26px; fill: transparent; stroke: currentColor; stroke-width: 2; }
.save-ribbon[aria-pressed="true"] svg { fill: currentColor; }
.saved-link { padding: 8px; font-size: 13px; font-weight: 650; }
- .mime-type { display: block; width: fit-content; margin-top: 2px; color: var(--dim); font-size: 11px; font-weight: 400; }
+ .mime-type { display: block; width: fit-content; margin-top: 2px; color: var(--dim); font-size: 12px; font-weight: 400; }
.media-error { margin: 20px; padding: 16px; background: var(--card); border-radius: 6px; text-align: center; }
.feed-media { position: relative; isolation: isolate; }
.feed-media::before { content: ""; position: absolute; inset: -32px; z-index: -1; pointer-events: none;
@@ -184,6 +187,8 @@
.body .gt { color: var(--green); }
.post-actions { display: flex; align-items: center; gap: 10px; padding: 13px 14px 15px; flex-wrap: wrap; }
.post-actions .source-button { margin-left: auto; font-size: 13px; }
+ .post-actions:has(.download-pack) { gap: 8px; }
+ .post-actions:has(.download-pack) > * { font-size: 12px; }
.comment-preview { padding: 0 14px 13px; font-size: 14px; }
.comment-preview p { margin: 4px 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.comment-preview .name { font-size: inherit; margin-right: 8px; }
@@ -626,7 +631,8 @@ function syncPieceFocus() {
if (!focused.isConnected || document.hidden || !document.hasFocus()) return;
const iframe = el("iframe");
iframe.title = focused.dataset.title;
- iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
+ iframe.setAttribute("sandbox", focused.dataset.packed === "true" ? "allow-scripts" : "allow-scripts allow-same-origin");
+ iframe.referrerPolicy = "no-referrer";
iframe.setAttribute("allow", "autoplay 'none'; camera 'none'; microphone 'none'");
iframe.addEventListener("load", () => { iframe.dataset.ready = "true"; }, { once: true });
iframe.src = focused.dataset.player;
@@ -650,6 +656,7 @@ function syncPieceFocus() {
const RUNNABLE = {
kidlisp: { prefix: "$", label: "KidLisp $", poster: true },
piece: { prefix: "", label: "piece ", poster: false },
+ whistlegraph: { prefix: "", label: "Whistlegraph ", poster: true },
};
function renderRunnable(post) {
@@ -662,17 +669,24 @@ function renderRunnable(post) {
// a card can never act as the signed-in user.
preview.dataset.player = "https://aesthetic.computer/" + address + "?nogap=true&nolabel=true&noauth=true";
preview.dataset.title = kind.label + code + (post.name ? " by " + post.name : "");
+ if (post.media.kind === "whistlegraph") {
+ preview.dataset.packed = "true";
+ preview.dataset.player = post.media.url;
+ preview.dataset.title = (post.media.title || code) + (post.name ? " by " + post.name : "");
+ }
if (!kind.poster) return preview;
const image = el("img");
image.alt = preview.dataset.title;
image.loading = "lazy";
image.decoding = "async";
image.addEventListener("load", () => { image.dataset.ready = "true"; }, { once: true });
- image.src = "https://oven.aesthetic.computer/grab/webp/512/512/" + encodeURIComponent(kind.prefix + code);
+ if (post.media.kind === "whistlegraph" && !post.file.poster) return preview;
+ image.src = post.media.kind === "whistlegraph" ? post.file.poster
+ : "https://oven.aesthetic.computer/grab/webp/512/512/" + encodeURIComponent(kind.prefix + code);
image.addEventListener("error", () => {
image.remove();
const link = el("a", null, "Open " + kind.prefix + code);
- link.href = "https://aesthetic.computer/" + address;
+ link.href = post.media.url || "https://aesthetic.computer/" + address;
preview.prepend(link);
}, { once: true });
preview.append(image);
@@ -868,15 +882,21 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false)
const byline = el("div", "byline");
byline.append(authorName(post.name));
if (root) {
- const source = el("a", "media-label", post.media ? post.media.kind + (post.media.code ? " " + post.media.code : "") : post.file?.name || "post " + post.code);
+ const source = el("a", "media-label", post.media?.title || (post.media ? post.media.kind + (post.media.code ? " " + post.media.code : "") : post.file?.name || "post " + post.code));
source.href = post.media?.url || "#/t/" + post.code;
byline.append(source);
+ if (post.origin) {
+ const origin = el("a", "media-origin", post.origin.name);
+ origin.href = post.origin.url;
+ byline.append(origin);
+ }
const mime = el("span", "mime-type", post.file?.type || post.board || "text/plain");
byline.append(mime);
}
const time = el("time", null, ago(post.when));
time.dateTime = post.when;
time.title = new Date(post.when).toLocaleString();
+ time.setAttribute("aria-label", time.title);
meta.append(avatar, byline, time);
if (root) meta.append(saveRibbon(post.code));
div.append(meta);
@@ -884,6 +904,13 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false)
const runnable = post.media?.code && Object.hasOwn(RUNNABLE, post.media.kind);
const file = runnable ? renderRunnable(post) : renderFile(post.file);
const frame = el("div", "feed-media");
+ if (runnable && post.media.packed) {
+ const aspect = ["2:3", "9:16", "1:1", "4:3", "16:9"].includes(post.media.aspect) ? post.media.aspect : "2:3";
+ const [width, height] = aspect.split(":").map(Number);
+ frame.classList.add("media-pack");
+ frame.style.setProperty("--media-ratio", `${width} / ${height}`);
+ frame.style.setProperty("--pack-ratio", width / height);
+ }
if (inFeed && file.tagName === "VIDEO") { file.muted = true; file.loop = true; }
if (post.media?.kind === "tape" && post.media.code) frame.dataset.tape = post.media.code;
frame.append(file);
@@ -926,11 +953,35 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false)
div.append(frame);
}
if (post.text) div.append(renderText(post.text));
- if (root && !inThread) {
+ if (root) {
const actions = el("div", "post-actions");
- const discuss = el("a", "discussion", "comment" + (post.replies ? " · " + post.replies : ""));
- discuss.href = "#/t/" + post.code + "?comment=1";
- actions.append(discuss);
+ if (!inThread) {
+ const discuss = el("a", "discussion", "comment" + (post.replies ? " · " + post.replies : ""));
+ discuss.href = "#/t/" + post.code + "?comment=1";
+ actions.append(discuss);
+ }
+ if (post.media?.packed) {
+ const download = el("button", "download-pack", ".html ↓");
+ download.setAttribute("aria-label", "Download " + post.file.name);
+ download.addEventListener("click", async () => {
+ download.disabled = true;
+ try {
+ const response = await fetch(post.file.url);
+ if (!response.ok) throw new Error("Download failed");
+ const url = URL.createObjectURL(new Blob([await response.arrayBuffer()], { type: "text/html" }));
+ const link = el("a"); link.href = url; link.download = post.file.name;
+ document.body.append(link); link.click(); link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
+ download.textContent = ".html ↓";
+ } catch { download.textContent = "Retry .html ↓"; }
+ finally { download.disabled = false; }
+ });
+ actions.append(download);
+ if (post.media.tokenUrl) {
+ const token = el("a", "token-link", "OBJKT ↗"); token.href = post.media.tokenUrl;
+ actions.append(token);
+ }
+ }
if (post.media?.url) {
const source = el("a", "source-button", "open ↗");
source.href = post.media.url;
@@ -938,7 +989,7 @@ function renderPost(post, isOp, inThread = false, contextBoard, inFeed = false)
actions.append(source);
}
div.append(actions);
- if (post.preview?.length) {
+ if (!inThread && post.preview?.length) {
const preview = el("div", "comment-preview");
for (const reply of post.preview) {
const line = el("p");
diff --git a/system/tests/mime-pack-browser.test.mjs b/system/tests/mime-pack-browser.test.mjs
new file mode 100644
index 0000000000..9a6a8b5c37
--- /dev/null
+++ b/system/tests/mime-pack-browser.test.mjs
@@ -0,0 +1,64 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFile, mkdir } from 'node:fs/promises';
+import { chromium } from 'playwright';
+
+test('packed Whistlegraphs retain attribution, aspect, isolated interaction and exact HTML downloads', async () => {
+ const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL, headless: true });
+ const context = await browser.newContext({ viewport: { width: 390, height: 844 }, acceptDownloads: true });
+ const page = await context.newPage();
+ const html = await readFile(new URL('../public/mime/index.html', import.meta.url), 'utf8');
+ const artifact = ``;
+ const post = { code: 'pack', parent: null, name: '@artist', when: '2026-10-05T22:00:00Z', replies: 0,
+ origin: { name: 'Whistlegraph', url: 'https://whistlegraph.app/' },
+ file: { name: 'wgDefen-v7.html', type: 'text/html', url: '/artifact.html' },
+ media: { kind: 'whistlegraph', code: 'wgDefen', title: 'Spinning tree', version: 7, aspect: '2:3', packed: true,
+ url: 'https://ipfs.aesthetic.computer/ipfs/fixture', tokenUrl: 'https://teia.art/objkt/885469' } };
+ await context.route('**/*', route => {
+ const u = new URL(route.request().url());
+ if (u.hostname === 'mime.ac' && u.pathname === '/') return route.fulfill({ contentType: 'text/html', body: html });
+ if (u.pathname === '/api/mime') return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ recent: [post, { ...post, code: 'pack2' }], hasMore: false }) });
+ if (u.pathname === '/ipfs/fixture' || u.pathname === '/artifact.html') return route.fulfill({ contentType: 'text/html', body: artifact });
+ return route.fulfill({ status: 200, body: '' });
+ });
+ try {
+ await page.goto('https://mime.ac/?ac-automation=1');
+ await page.locator('#pack iframe[data-ready]').waitFor();
+ assert.equal(await page.locator('#pack .name').textContent(), '@artist');
+ assert.equal(await page.locator('#pack .media-label').textContent(), 'Spinning tree');
+ assert.equal(await page.locator('#pack .media-origin').textContent(), 'Whistlegraph');
+ assert.equal(await page.locator('#pack .mime-type').textContent(), 'text/html');
+ assert.equal(await page.locator('#pack time').getAttribute('datetime'), post.when);
+ assert.equal(await page.locator('#pack iframe').getAttribute('sandbox'), 'allow-scripts');
+ assert.equal(await page.locator('#pack .token-link').getAttribute('href'), post.media.tokenUrl);
+ assert.equal(await page.locator('#pack .piece-preview').evaluate(el => el.inert), true);
+ const framed = page.frames().find(frame => frame.url() === post.media.url);
+ assert.equal(await framed.evaluate(() => document.body.dataset.running), 'yes', 'pack scripts run');
+ assert.equal(await page.evaluate(() => document.body.dataset.escaped || localStorage.getItem('escape')), null);
+ for (const viewport of [{ width: 390, height: 844 }, { width: 900, height: 900 }]) {
+ await page.setViewportSize(viewport);
+ const box = await page.locator('#pack .media-pack').boundingBox();
+ assert.ok(Math.abs(box.width / box.height - 2 / 3) < .01, JSON.stringify(box));
+ assert.ok(box.height <= viewport.height * .56, 'preview leaves room for feed metadata');
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
+ }
+ await page.locator('#pack .media-toggle').click();
+ await page.frameLocator('#pack iframe').getByRole('button', { name: 'Spin tree' }).click();
+ await page.frameLocator('#pack iframe').getByRole('button', { name: 'Spinning', exact: true }).waitFor();
+ await page.locator('#pack .media-toggle').click();
+ const downloaded = page.waitForEvent('download');
+ await page.locator('#pack .download-pack').click();
+ const download = await downloaded;
+ assert.equal(download.suggestedFilename(), 'wgDefen-v7.html');
+ assert.equal(await readFile(await download.path(), 'utf8'), artifact);
+ await page.locator('#pack2 .media-pack').evaluate(el => el.scrollIntoView({ block: 'center' }));
+ await page.locator('#pack2 iframe[data-ready]').waitFor();
+ assert.equal(await page.locator('.piece-preview iframe').count(), 1, 'only the visible pack runs');
+ if (process.env.MIME_SCREENSHOT_DIR) {
+ await mkdir(process.env.MIME_SCREENSHOT_DIR, { recursive: true });
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.evaluate(() => scrollTo(0, 0));
+ await page.screenshot({ path: `${process.env.MIME_SCREENSHOT_DIR}/mime-pack-mobile.png` });
+ }
+ } finally { await context.close(); await browser.close(); }
+});