Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,19 @@ jobs:
- name: Install dependencies
run: npm ci

# The key that opens the sealed 3D models (scripts/encrypt-model.mjs).
# It reaches the build only here, so CI and every fork build the site
# without it, and their pages simply carry no model. Like the Cloudflare
# credentials it lives in the production environment, and a missing key
# is not an error: the site publishes without the models.
- name: Validate before shipping
env:
PHASE_ZERO_MODEL_KEY: ${{ secrets.PHASE_ZERO_MODEL_KEY }}
run: npm run validate

- name: Build the site
env:
PHASE_ZERO_MODEL_KEY: ${{ secrets.PHASE_ZERO_MODEL_KEY }}
run: npm run build

# Whether the credentials are there has to be worked out in a step, not
Expand Down
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ These are enforced by CI. A pull request that breaks them cannot be merged.
the rule is about the films, and about anything that is somebody else's to
licence.

There is one deliberate exception, decided by the maintainer: a 3D model
of a collection piece, such as an armour, may be shown on that piece's
entry. It is narrow, and every condition is required:

- The model's licence lets this project show it. Read the licence itself,
not the summary on the download page.
- The file is committed sealed with `scripts/encrypt-model.mjs`, never in
the form it was downloaded, and the key lives only in the production
environment's secrets. A clone or a fork gets noise and a page that
works without it.
- The author, the licence, the page it came from and the owner of the
design are recorded in the piece's `model` field, and shown with it.
- It is shown only when the reader asks. The page is complete without it.
- Nothing else follows from it. No posters, no stills, no logos, no
photographs, and no model of a person. If a rights holder or the model's
author asks for it to go, it goes, and the entry reads as it did before.

## Verifying a fact

The workflow is the same whether you are a person or an agent:
Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ forks it to inherit that risk.
Everything you see is vector artwork generated from the data in this repository.
This is also why the site looks like itself and not like every other Marvel site.

The one exception is a licensed 3D model of a collection piece, shown on that
piece's entry when the reader asks for it. It is committed sealed, the key is a
deploy secret, and the people who made it are credited in the piece's data, so
a fork inherits a file it cannot open rather than one it is not allowed to
hold. The conditions are listed in `CLAUDE.md` under rule 5. Open an issue
before proposing a new model.

## The sourcing rule

Every file in `data/` ends with a block like this:
Expand Down
24 changes: 21 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,11 @@ real second variant, not an inversion: the void becomes paper, the liveries
become the same hues rendered dark enough to be read on paper, and every value
was checked against its own ground rather than assumed to survive the flip. The
whole identity is generated vector work and typography, because it has to be:
there is no imagery of the films in this project and there never will be. The
only mark from outside is the GitHub logo in the colophon, drawn here, pointing
at the repository.
there is no imagery of the films in this project. The only mark from outside is
the GitHub logo in the colophon, drawn here, pointing at the repository. The one
licensed object is a collection piece's 3D model, shown on request inside that
piece's entry (see Piece Model below); it is content the reader opens, never
part of the identity.

**Key Characteristics:**

Expand Down Expand Up @@ -457,6 +459,22 @@ A collection entry. `--panel` background, hairline border with the top border at
`--ink-soft`, count in `--ink-faint`. Lifts 3px on hover with the top border
warming to 60 percent.

### Piece Model

A licensed 3D model inside a collection piece, closed until asked for. The
control is the share card's pattern in the piece's colour: label typography,
hairline border, a 2px inline-start edge in `--piece-colour`, washing to 12
percent piece colour on hover and while open. The stage takes the set card's
material: flat `--panel`, hairline, a top border at 46 percent piece colour,
and a radial 14 percent wash of that colour as the light the model stands in.
The canvas is transparent: no floor, no backdrop, nothing drawn under the
model. Its rim light is `--piece-colour` and anything that glows is
`--tint-blue`, both read from the stylesheet and re-read when the theme
changes. Turn, lights and take-apart are an ordinary Switch, amber when
pressed. The credit sits below in the piece's source-line style: data size,
`--ink-faint`, labels in the label role, the link in piece colour. It holds
still under reduced motion and draws only while open and on screen.

### Search Field

Full width to a 26rem maximum, `--panel` background, 1px hairline, `--core`
Expand Down
12 changes: 12 additions & 0 deletions content/en/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@
"piece.designation": "Known as",
"piece.firstSeen": "First seen in",
"piece.lastSeen": "Last seen in",
"piece.model.credit": "3D model",
"piece.model.creditLine": "\"{name}\" by {author}, {licence}",
"piece.model.design": "Design",
"piece.model.error": "The model could not be opened in this browser.",
"piece.model.explode": "Take it apart",
"piece.model.hide": "Hide the model",
"piece.model.hint": "Drag to turn it. Once you have, scroll or pinch to come closer.",
"piece.model.label": "A 3D model of the {name} that can be turned and taken apart",
"piece.model.loading": "Opening the model",
"piece.model.lights": "Lights",
"piece.model.rotate": "Turn",
"piece.model.show": "View in 3D",
"portal.characters": "Who these people are and their thread through the catalogue.",
"portal.collections": "The armours, the stones, the forms. Named things, explained.",
"portal.glossary": "The words this material never stops to define.",
Expand Down
12 changes: 12 additions & 0 deletions content/it/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@
"piece.designation": "Conosciuta come",
"piece.firstSeen": "Vista per la prima volta in",
"piece.lastSeen": "Vista l'ultima volta in",
"piece.model.credit": "Modello 3D",
"piece.model.creditLine": "\"{name}\" di {author}, {licence}",
"piece.model.design": "Design",
"piece.model.error": "Non è stato possibile aprire il modello in questo browser.",
"piece.model.explode": "Smonta",
"piece.model.hide": "Nascondi il modello",
"piece.model.hint": "Trascina per girarla. Poi, con la rotella o le dita, ti avvicini.",
"piece.model.label": "Un modello 3D della {name} che si può girare e smontare",
"piece.model.loading": "Apertura del modello",
"piece.model.lights": "Luci",
"piece.model.rotate": "Rotazione",
"piece.model.show": "Vedi in 3D",
"portal.characters": "Chi sono queste persone e il loro filo dentro il catalogo.",
"portal.collections": "Le armature, le gemme, le forme. Cose con un nome, spiegate.",
"portal.glossary": "Le parole che questo materiale non si ferma mai a definire.",
Expand Down
8 changes: 8 additions & 0 deletions data/sets/iron-man-armour/mark-iii.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ order: 3
tint: red
firstAppearance: iron-man
lastAppearance: iron-man
model:
file: models/iron-man-armour/mark-iii.bin
name: "Iron Man"
author: "-LUCIFER-"
licence: "Royalty Free License (no AI)"
design: "Marvel Studios"
url: "https://www.cgtrader.com/free-3d-models/character/man/iron-man-0d09ff19-c384-4443-9eb5-d6712c9d2588"
accessed: 2026-10-03
sources:
- url: https://en.wikipedia.org/wiki/Iron_Man's_armor_(Marvel_Cinematic_Universe)
title: "Iron Man's armor (Marvel Cinematic Universe), Wikipedia"
Expand Down
49 changes: 49 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@
"@fontsource-variable/jost": "^5.3.0",
"astro": "^7.3.1",
"remark-directive": "^4.0.0",
"three": "0.160.0",
"unist-util-visit": "^5.1.0",
"zod": "^4.5.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"@types/three": "0.160.0",
"puppeteer-core": "^25.10.0",
"typescript": "^5.9.3",
"yaml": "^2.6.0"
Expand Down
Binary file added public/models/iron-man-armour/mark-iii.bin
Binary file not shown.
6 changes: 4 additions & 2 deletions scripts/build-headers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ const policy = [
anywhere and a policy of 'self' alone silently drops them. Checked with
the browser: without data: here, two of the typefaces never arrive. */
`font-src 'self' data:`,
// The site makes no requests of its own. Nothing to allow.
`connect-src 'none'`,
/* The one request the site makes of its own: a collection piece's 3D model,
fetched from this origin when a reader asks to see it. Nothing else is
fetched, and nothing is ever fetched from anywhere but here. */
`connect-src 'self'`,
// There are no forms, so any form that appears is not ours.
`form-action 'none'`,
`frame-ancestors 'none'`,
Expand Down
61 changes: 61 additions & 0 deletions scripts/encrypt-model.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/*
* Seals a licensed 3D model so it can sit in a public repository.
*
* Some models shown on the site are licensed to this project but not to
* everybody who clones it. Their licences allow them to be shown inside a
* work and forbid handing the file out in the form it was downloaded. A
* public repository hands out every file in it, so a model of that kind is
* committed sealed: compressed, then encrypted with AES-256-GCM, with the
* key kept out of the repository as a deploy secret.
*
* The published site is given the key at build time and opens the model in
* the reader's browser. A clone or a fork without the key gets a file that is
* noise, and the page it belongs to renders without the model, which is a
* working state and not a broken one.
*
* Usage:
* PHASE_ZERO_MODEL_KEY=<key> node scripts/encrypt-model.mjs <in.glb> <out.bin>
*
* Without PHASE_ZERO_MODEL_KEY a new key is generated and printed once. Put it
* in .env for local builds and in the production environment's secrets for
* the deploy. It is never written anywhere by this script.
*
* The sealed file is: four bytes of magic ("PZM1"), a twelve byte nonce, then
* the gzip of the model encrypted with the tag appended, which is the layout
* the browser's own AES-GCM expects.
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { createCipheriv, randomBytes } from 'node:crypto';
import { gzipSync, constants } from 'node:zlib';

const [input, output] = process.argv.slice(2);
if (!input || !output) {
console.error('Usage: node scripts/encrypt-model.mjs <in.glb> <out.bin>');
process.exit(1);
}

let key;
const given = process.env.PHASE_ZERO_MODEL_KEY;
if (given) {
key = Buffer.from(given, 'base64url');
if (key.length !== 32) {
console.error('PHASE_ZERO_MODEL_KEY must be 32 bytes, written as base64url.');
process.exit(1);
}
} else {
key = randomBytes(32);
console.log('No key given, so a new one was made. Keep it as PHASE_ZERO_MODEL_KEY:');
console.log(key.toString('base64url'));
}

const model = readFileSync(input);
const packed = gzipSync(model, { level: constants.Z_BEST_COMPRESSION });
const nonce = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, nonce);
const sealed = Buffer.concat([cipher.update(packed), cipher.final(), cipher.getAuthTag()]);

mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, Buffer.concat([Buffer.from('PZM1'), nonce, sealed]));
console.log(`Sealed ${input} (${model.length} bytes) into ${output} (${sealed.length + 16} bytes).`);
Loading
Loading