Collections — an explorer for many images¶
Use this reference when the deliverable is a set of images that a person will survey, filter, compare or check, rather than one composed figure. muriel's default engine for this is Wunderkammer 0.3.0 (MIT), a collection explorer for people and language models:
- The agent writes the collection as one JSON document.
- The person pans, zooms, filters by facets, groups, compares groups and opens items.
- The agent reads back exactly what the person selected.
It runs from static files with no server.
Wunderkammer's spatial view uses muriel's rendering primitives, vendored at a
pinned commit
(wall/spatial/lib/VENDOR.md).
muriel imports no Wunderkammer code, and this reference names it by released
version only. Every URL below is pinned to the v0.3.0 tag, the agent skill
included. When a new version is released, update the pins
and re-check the claims that carry a date.
This reference is the front door. Wunderkammer is designed to be used
from muriel: this reference decides when an explorer is the right
representation, muriel.collection
builds the document, and the exploration-quality axis
and contrast checks review the result. For the engine's own steps and traps
(URL keys, hosting, observations, the hand-back), read Wunderkammer's skill,
skills/wunderkammer/SKILL.md.
Its examples are tested against the pages' validators, so where it or the
contract disagrees with the summary below, they win. Agents without muriel
can install that skill on its own (npx skills add andyed/wunderkammer).
It is a default, not a requirement. Another explorer that passes the exploration-quality axis is a sensible substitute: the axis is muriel's opinion, and the engine is a preference.
When to reach for a collection explorer¶
Use one when at least one of these holds:
- Dozens to thousands of images. Below a dozen, a figure grid or contact sheet is faster to read than an explorer is to learn. Above that, a person needs an overview, filters and a way back to where they were. A document can list 1 to 50,000 items. The bundled example has 297.
- A comparison between groups. The question is how an attribute is
distributed across 2–6 groups (decades, sources, conditions), and the person
should see the items in each group, not only their shares. If the images are
irrelevant to the question, use
channels/charts.md. - Claims a person should be able to check. The agent says something about the collection, such as "jazz is less common in the 1990s", and the person should be able to open the exact items behind it.
Do not use one for a single image or composed figure, or for numbers with no images behind them. When the output must be one self-contained file, see the fallback.
An arrival, or a video. ?view=warehouse (since 0.3.0; also asked for as
the "matrix guns view") opens the wall's aisle with "Cats. Lots of cats."
played over it once. Rows of racks rush out of a white void, and then the
lights go down on the aisle, ready to browse. The word in the caption is the
document's ui.nouns.items. Wunderkammer's scripts/warehouse-video.mjs
renders the same frames to an MP4; --tour 30 ends it with half a minute
panning over every cover. See its
AISLE.md.
From Apple Photos. On a Mac, Wunderkammer's
scripts/photos-collection.py builds the document from the Photos library,
read-only, through osxphotos. It finds one subject (a pet, a person, a trip)
by Photos' labels, captions or named people and pets, then keeps a tidy
150: one per burst, spread over time, and with --animal dog, only photos
where the dog can be seen. Its images carry no location. It is
macOS-only, so it lives in the engine, not in muriel.collection; see
APPLE_PHOTOS.md.
The contract in brief¶
There are four parts, and all of them are static files. The full contract is
docs/CONTRACT.md.
This section summarizes it for design decisions; for the engine's steps,
see Wunderkammer's skill above.
Collection document: wunderkammer.collection v1¶
{
"format": "wunderkammer.collection",
"version": 1,
"corpus": { "id": "example:album-covers", "revision": "2026-10-17" },
"ui": { "title": "Album covers", "defaultGroup": "decade",
"facetOrder": ["decade", "genre"], "hiddenFacets": ["catalog"] },
"images": { "cors": true },
"clips": [
{ "id": "a1", "title": "Example title",
"cover": "https://example.org/a1-256.webp",
"props": { "decade": "1980s", "genre": ["electronic", "pop"], "catalog": "XL-104" },
"sourceUrl": "https://example.org/a1" }
]
}
- Required fields:
format,version,corpus.idandclips(1 to 50,000 items). Each clip needs a non-emptyid. - Identity: an item's identity is its
url, or itsidwhen it has nourl. Repeated identities are an error. - Attributes go in
propsas strings, finite numbers, booleans or arrays of them. Facets and their counts are derived fromprops, so supply neither yourself. Hide per-item identifiers withui.hiddenFacets. Apropskey that names a clip field (title,source…) is refused. - Links and images:
sourceUrlmust be http(s). Images (cover,detailCover,coverVariants[].src,media[].src) may be http(s),data:image/orblob:. Relative URLs resolve against the document's URL. - Cross-origin covers:
images.cors: truedeclares that covers on other origins send CORS headers. Without CORS, those covers stay blank in the 3D views. - Plain text only: every string is set as text (
textContent).noteis never HTML. - Validation: unknown keys are refused, with the list of supported ones.
All problems are reported at once with their paths, up to 25 in one
CollectionError, so a first draft can be fixed in a single pass. - Sprite sheets: with a few hundred covers or more, pack them into
sprite-sheet pages with
scripts/collection-sheets.py. On the 297-image example, the overview needed 3 image requests instead of 297 (docs/PERF.md, "Collection sprite sheets").
Comparison recipe¶
A question-sized comparison: 2–6 groups of one attribute, one measured
attribute, and counts or shares. Every field is required, though question
and scope.query may be empty strings.
{
"version": 1,
"question": "How does genre distribution change across release decades?",
"corpus": { "id": "example:album-covers", "revision": "2026-10-17" },
"scope": { "query": "", "facets": {} },
"groupBy": "decade", "groups": ["1970s", "1980s", "1990s"],
"measure": "genre", "metric": "share",
"categoryLimit": 8, "sort": "difference"
}
- Allowed values:
metric:shareorcount;sort:difference,totalorname;categoryLimit: an integer from 4 to 20.- Corpus: repeat the collection's
corpus.idandcorpus.revision. Otherwise the page notes that the recipe names a different corpus. - Validation:
validateComparison()stops at the first problem, unlike the collection and observation validators. - Counting: the rules are stated in the evidence packet's
countingPolicy: - an item can fall in several groups and carry several values, so shares need not sum to one;
- an absent attribute counts as
(Unknown); - a share with a zero denominator is
null.
Observations: wunderkammer.observations v1¶
An observation is a claim about a comparison. It points at the cells or items behind it rather than summarizing them.
{
"format": "wunderkammer.observations", "version": 1,
"comparison": { "version": 1, "...": "the recipe above, unchanged" },
"observations": [
{ "id": "jazz-falls",
"text": "Jazz is less common in the 1990s than in the 1970s.",
"evidence": { "cells": [{ "value": "Jazz", "cohort": "1970s" },
{ "value": "Jazz", "cohort": "1990s" }],
"items": ["a1"] } }
]
}
- The figures are computed by the page. Each cited cell's count, denominator and share are taken from the same rows as the evidence export. In the panel, the text is labelled as the author's and the figures as computed. v1 does not parse numbers out of the text, so an authored claim has no numbers of its own. State the direction and cite the cells. The size is then shown by the page.
- Limits:
- 12 observations;
- 600 characters of text, 12 cells and 200 items per observation;
- ids of 1–40 letters, digits,
-or_. - Cells: a cell's
cohortmust be one of the recipe's groups. A cell without one cites its value in every group. - Unresolved citations are shown, never dropped. These are a value the measure lacks, a group the recipe lacks, or an item outside the collection.
- Staleness: observations apply to the comparison they were written for. After the person changes it, they are shown without figures, with a button back to the original comparison.
- Delivery: two static files,
wall/compare.html?collection=<collection.json>&observations=<observations.json>. Once loaded, the observations travel in the page's link.
Hand-back: window.wunderkammer.export()¶
Both pages define window.wunderkammer.export(). Only identities and titles
are in the packet, never notes or paths.
- The wall returns
{ format: "wunderkammer.export", version: 1, … }: corpus;scope: label, query, facets and branch counts;view: mode and grouping;focus: the focused item;highlight: the pinned highlight with its matching keys;countandtotal;evidence: every in-scope item as{ key, title }, in corpus order.- The comparison page returns the evidence packet for the current selection:
- cohorts with their denominators;
- rows of cells with count, share and keys;
countingPolicy,coverageandwarnings;- the selected items.
For a selected observation, the packet adds observation, with its computed
figures and any unresolved citations.
- Reading it: open the page as the top-level document in a browser the
agent drives, and evaluate window.wunderkammer.export() with the
page-JavaScript tool. In the Claude Code desktop app, that is the browser
pane's javascript_tool.
- Frames: a same-origin frame can be read as
frame.contentWindow.wunderkammer.export(), but a cross-origin frame cannot.
- Not built in 0.3.0: an onSelection callback through mount(), and a
"copy for the conversation" button.
- Before acting on a packet, check that its count matches the scope the
person described. Quote identities from the packet rather than from memory
of the page.
Embedding: mount(element, collection)¶
// A checkout of v0.3.0, served by the same static host as this page.
import { mount } from './wunderkammer/wall/embed.mjs';
const wall = mount(element, collection); // frames ./wunderkammer/index.html
await wall.ready; // resolves on the wall's view-ready message
collectionis an object or a URL.- An object is validated in the host page first, so a bad one throws a
CollectionErrorbefore any frame exists. It is then posted to the wall's origin only, and validated again there. - A URL is fetched by the wall. If it fails, the host gets a generic error and the details appear inside the frame.
wallUrldefaults to the wall page,index.htmlat the checkout's root, one folder aboveembed.mjs. Imported from a static host, the default works as it is. The module can also be imported from jsDelivr's GitHub route,https://cdn.jsdelivr.net/gh/andyed/wunderkammer@v0.3.0/wall/embed.mjs, but its default is then a page served astext/plain. Passmount(element, collection, { wallUrl })with a static host's wall page.- Other options:
title(the iframe's title) andtimeout(default 20,000 ms). wall.readyrejects on the wall's error message or on timeout.wall.destroy()removes the frame.
Loading by pinned version¶
Wunderkammer is released as git tags on GitHub. Pin every reference to the
tag, v0.3.0: in a GitHub URL, in a jsDelivr GitHub-route URL, and as the
checkout a static host serves. Never use a branch, @latest, or a
package-registry URL. No Wunderkammer package is published, so such a URL
fails today, and would serve someone else's code if they registered the name.
| What | jsDelivr GitHub route (cdn.jsdelivr.net/gh/andyed/wunderkammer@v0.3.0/…) |
Static host serving a checkout of the tag |
|---|---|---|
ES modules (wall/embed.mjs, wall/collection.mjs, wall/observations.mjs) |
yes | yes |
The wall (index.html) and wall/compare.html, opened or framed |
no: HTML is served as text/plain |
yes |
- Content types were checked on 2026-10-18 on the GitHub route at
v0.1.0:index.htmlandwall/compare.htmlare served astext/plain, andwall/embed.mjsasapplication/javascript. - Where the pages are. The wall is
index.htmlat the checkout's root, with its assets underwall/:<host>/<path>/index.html?collection=<url>. The comparison page iswall/compare.html. Both name their assets by relative paths, so they work at any sub-path, such as a GitHub Pages project site.tests/cdn-subpath.test.mjsserves the release's files under a versioned sub-path. From there it boots the wall,mount()with its default, and the comparison page's link back, with no request leaving that path. - A static host: a checkout of the tag
(
git clone --branch v0.3.0 https://github.com/andyed/wunderkammer, thennpx http-server -p 8080 .and/?collection=<url>), the output ofscripts/build-demo.mjs, or a wunderkammer server. All of them serve the wall at/. - To try a document without hosting the wall, open the
live demo with
?collection=<url>, which takes precedence over the demo's own collection. The document must be served with CORS. The demo is not pinned to a tag, so validate for the pinned release anyway. - Framing from an artifact host is untested. The live demo sends no
X-Frame-Optionsorframe-ancestorsheader (checked 2026-10-18), so framing it is governed by the host page's own content security policy. A static host of your own sets its own headers. - Three.js 0.186.1 is loaded from jsDelivr by both pages, for the 3D views (the spatial view, the aisle and the floating space) and the album walls only. Without the network or WebGL, those views are withdrawn with a notice. The flat wall, facets, detail pane, comparison matrix and evidence list remain usable.
Fallback¶
Use muriel's linked-atlas lab instead of Wunderkammer when:
- A single file is required. Wunderkammer is a wall page, its modules and a collection document, with Three.js from a CDN, and it cannot be inlined into one HTML file. The lab has no runtime dependencies: one page, one stylesheet and three small ES modules, all of which can be inlined.
- WebGL is unavailable and the task needs the views that use it. Wunderkammer's 3D view and album walls need WebGL. Its flat wall and comparison matrix do not, so check which views the task needs before switching.
The lab is drawn in SVG and HTML, and includes:
- semantic zoom with hysteresis, and an overview linked to a detail view;
- brushing with replace, add, subtract and intersect;
- summaries that show selected/total denominators;
- an accessible record table;
- undo;
- a read-only
window.linkedAtlas.snapshot().
It is a reference implementation of the interaction contract for 192 synthetic records, not a collection engine. It has no image tiles, document validator, comparison recipe or observations. Adopting it means writing those against its model while keeping its selection and camera semantics.
Exploration quality¶
This is a critique axis for any collection page, whether Wunderkammer or a substitute. Each question is answered from evidence an agent can gather:
- a screenshot at fit-to-all;
- one after zooming into a group;
- one after opening and closing an item;
- the hand-back packet.
Report each failure with its evidence, in
muriel-critique's severity scale.
| Question | Passes when | Evidence | Severity if it fails |
|---|---|---|---|
| Does the overview show the collection's structure? | At fit-to-all, every item is on screen, grouped by an attribute that answers the person's first question, each group is labelled with its count, and the total is shown. | Fit-to-all screenshot, and export().total against the document's item count. |
HIGH: for example, an ungrouped grid, or grouping by a per-item attribute such as a file name. |
| Are the facets useful, with counts? | Each visible facet splits the collection into two or more values, none of which covers nearly every item. Every value shows its count, with the count's meaning named (Wunderkammer: In view / available). Facets have readable labels (ui.facetLabels), and per-item identifiers are hidden. |
Facet-rail screenshot. For each props key, the number of distinct values against the number of items. |
MEDIUM. HIGH when counts are missing. |
| Does zoom keep orientation? | After zooming into a group, the group's name is still visible. The whole collection is one action away (Fit or Back). Opening and closing an item returns to the same view. A zoom limit is a no-op, not a jump. | Before and after screenshots, and export().scope and export().view before and after opening an item. |
HIGH. |
| Can a selection be handed back? | The person's scope, focus and selection can be read as exact identities, the packet's count equals the count on screen, and the packet holds no notes or local paths. | The hand-back packet against the on-screen count. | HIGH when there is no packet or the counts disagree. CRITICAL when private fields leak. |
| Do observations cite evidence? | Every observation cites at least one cell or item, and none is unresolved. The text contains no figures of its own. Selecting an observation lights exactly its cited items. | Observation panel, and the packet's observation.figures and observation.unresolved. |
CRITICAL when an authored number disagrees with the computed figure. HIGH for an observation with nothing resolvable. |
The universal rules still apply. Facet labels, counts, group titles and observation text are readable text under the 8:1 floor:
- Wunderkammer's own audit: its facet and scope controls measured at least
8.528:1 against their painted backgrounds
(
docs/FACETING.md). That audit did not cover every text element. - The embedding page: run
python -m muriel.contrast <page.html>on the page you wrote. - The rest of the wall's text: measure it on a capture
(
python -m muriel.capture <url>).
Building a document: muriel.collection¶
python -m muriel.collection build <folder|csv> --out collection.json [--title T] [--group FIELD]
python -m muriel.collection validate collection.json [--release 0.1.0|next]
buildreads a folder of images or a CSV and writes awunderkammer.collectionv1 document.--titlenames the collection, and--groupnames the attribute the wall groups by when it opens.validatefollows muriel's CLI convention for exit codes:- exit 0: the document is valid;
- exit 1: every problem is listed with its path;
- exit 2: nothing was read.
- Validate before opening a browser. A collection given to a frame by URL
reports its problems inside the frame, not to the host. The page validates
again on load, and when the two validators disagree, the page's
(
wall/collection.mjs) is the reference. --releasenames the wall that will load the document. Each release refuses the fields added after it. The default is 0.3.0, the release this reference pins; it adds no collection fields, so a document valid for 0.2.0 loads in it. For a 0.1.0 wall, validate with--release 0.1.0: it refusesui.nouns(what the counts call one item and several), which 0.2.0 added.--release nextchecks for Wunderkammer's unreleased work after 0.3.0, which adds a clip'svideo: one http(s) orblob:URL, neverdata:, played muted and looping over the clip's cover in the 3D views and the aisle, and with controls in the detail pane. A 0.3.0 wall refuses it, so the default check refuses it too.- Observations need Wunderkammer's checker.
muriel.collectionvalidates the collection document only. From a Wunderkammer checkout,node scripts/validate-collection.mjs collection.json --observations observations.jsonalso prints the figures each claim will show and every citation that does not resolve.
A typical pass:
- Build and validate the document, then fix every listed problem.
- Open the wall (
index.html) with?collection=and check the overview against the exploration-quality axis. - For a comparison, write the recipe. For claims, write observations that
cite cells. Open
wall/compare.htmlwith both. - Let the person explore, then read the hand-back and quote the identities in it.
Anti-patterns¶
- Numbers in observation text. Cite the cell, and let the page show the figure.
- A summary in place of the collection. Show the collection, and use observations as pointers into it.
@latest, a branch URL, a package-registry URL, or the wall page from jsDelivr. Pin the tag, and serve HTML from a static host.- HTML in
noteortitle. It is shown as literal text. - Treating the hand-back as the person's conclusion. It records what they selected, not what they concluded from it.