wolves-content · git:20260818.5b89dea · 2026-08-18 · sha256 3b67cb7faad05bcc
wolves-content git:20260818.5b89deaA
Immutable. This exact content is served forever at /api/v1/blob/3b67cb7faad05bcc.
---
name: wolves-content
description: Use when editing Wolves lore, signals, characters, soundtrack metadata, gallery data, or approved images.
---
# Wolves content
## Overview
Maintain Wolves content without changing the frozen runtime design.
## When to Use
Use for lore, incoming signals, dinosaurs, guardian bonds, intro data, music
metadata, galleries, and slideshow assets.
## When NOT to Use
Do not use for components, templates, styles, controls, layout, animation,
player synchronization, or generated manifests.
## Core Process
1. Read `../../reference/wolves-runtime.md`.
2. Resolve any video ordinal or timestamp in
`../../reference/wolves-video-order.md` before opening a file.
3. Match the request to an open content surface.
4. Use exact user-supplied or recovered authored copy.
5. Add manifest entries for new registered records.
6. Regenerate generated files with their scripts.
7. Run the relevant tests, build, and browser checks.
## Resolve the artifact before you audit it
"The first video" is the Director's Cut prologue. The running order and source
ownership are in `../../reference/wolves-video-order.md` — read it before
opening a file for any request naming a video ordinal or a timestamp.
Answer "what is on screen at m:ss" with the show's own data, never by eye:
```bash
node scripts/wolves-cue-at.mjs 1:50
```
Screenshotting the seconds *around* a reported timestamp is not verification.
Quote the cue text in your report. A cue's shot outlives its text, so "the shot
contains 1:50" and "words are on screen at 1:50" are different questions; the
tool answers the second.
## Red Flags
- A `.vue`, style, or runtime synchronization file changes.
- Authored prose is generated or summarized.
- A generated manifest is hand-edited.
- Text moves between signal, thesis, lore, and chat layers.
- A video ordinal or timestamp is answered without `scripts/wolves-cue-at.mjs`.
## Common Rationalizations
| Rationalization | Reality |
|---|---|
| "It is in an official press kit, so it is freely licensed." | Availability is not a reuse license; record the governing policy and approved usage basis. See [`references/licensing-and-provenance.md`](references/licensing-and-provenance.md). |
| "The audience will not notice one missing image." | A late or failed image is a visible broken beat in an unattended theater show; validate every local asset. |
| "A short quote can be paraphrased safely." | Quotes and attribution are authored content; preserve exact verified wording or omit them. |
| "These cut windows are short, so rebasing them from zero is simpler." | Director's Cut keep ranges are pinned to absolute YouTube source timestamps; rebasing them breaks playback timing and the intro timeline math. |
| "This overlay text is obvious enough to paraphrase." | Wolves content surfaces use exact supplied wording only; changing even a short overlay changes authored content. |
| "It is only intro data, so I don't need to update tests." | Intro segment ids and timestamps are contract data for store and overlay tests; pin them when they change. |
| "I checked the seconds either side, so the timestamp is fine." | A cue can sit entirely between two probes. Resolve it with the lookup tool and quote the cue text. |
| "The vision model scored the pool, so its top picks are vetted." | Roughly one in five model top picks mislabels what is literally in the frame; use the scores to narrow the pool, then confirm every finalist by eye before it enters a registry. See [`references/galleries-and-artwork.md`](references/galleries-and-artwork.md). |
| "The rejection criteria are obvious, so the scoring pass can start." | A wrong disqualifying criterion silently discards the best material and the scores look plausible either way; confirm the criteria with the owner before an expensive pass. |
| "This asset has no source page, so a plausible upstream URL will do." | A fabricated link is read as verified evidence by the next agent; record the gap as `provenance: 'owner-supplied-local'`. See [`references/licensing-and-provenance.md`](references/licensing-and-provenance.md). |
## Detail
Load only the reference the change needs.
| Reference | Covers |
|---|---|
| [`references/projection-typography.md`](references/projection-typography.md) | Paging at thoughts, measure caps, photo fitting, overlay contrast, readability inside a locked range. |
| [`references/galleries-and-artwork.md`](references/galleries-and-artwork.md) | Gallery pools, photo sourcing and model-assisted curation, captions, hero labels, wallpaper numbering. |
| [`references/licensing-and-provenance.md`](references/licensing-and-provenance.md) | Third-party asset rights, including the Bungie fan-content guidelines, and recording provenance gaps honestly. |
| [`references/video-and-scene-work.md`](references/video-and-scene-work.md) | Source clips, keep ranges, encoders, reusable silent scene masters. |
| [`references/directors-cut-intro.md`](references/directors-cut-intro.md) | Composing either intro variant, and keeping content out of the standard show. |
## Verification
- [ ] Diff contains only documented content surfaces.
- [ ] Authored copy is exact.
- [ ] Generated files were regenerated from source.
- [ ] Affected player timestamps were checked when applicable.
- [ ] `../validation/SKILL.md` is complete.
## Sources
- Context7: `/addyosmani/agent-skills` (skill file structure and required sections)
- Context7: `/websites/ffmpeg_documentation` (encoder discovery and `-c:v libx264`)
- Context7: `/yt-dlp/yt-dlp` (format selection and output templates)
## Pages break at thoughts, not at character counts
`splitReadableBeats()` splits on sentence punctuation and then on a character
budget. Left alone, that budget breaks wherever the count runs out — after
`Dr.`, or on a stranded preposition. Both happened in the closing bulletin and
between them they cut the show's central reveal into pieces.
`readable-beats.ts` guards this in three stages:
- `mergeAbbreviationSplits()` rejoins sentences split at a title's period.
- `fuseTitledNames()` fuses a title with the capitalised words after it into one
unbreakable token, so "Dr. Andy Anderson" is laid out as a single unit.
- `settleBreaks()` repairs a page that ends on a dangling function word by
moving the whole trailing phrase to the next page. It only touches pages that
end badly; a page ending on a complete thought is already a good page.
The measurable target: **no page ends on a dangling function word.** At the time
of writing that holds for all 338 pages in the show.
When touching this file, verify no page overflows its budget afterwards. A fuse
that is too greedy silently produces pages too tall to read from the back row:
```bash
npx vite-node <probe that pages every record and compares against
PROSE_PAGE_CHARACTERS / CHAT_PAGE_CHARACTERS>
```
At the time of writing: 338 pages, zero over budget, zero ending on a dangling
word, worst page 150 characters against a 190 budget.
`src/tests/wolvesFinaleReveal.test.ts` asserts the dangling-word rule across
every record, so a greedy change to the splitter fails immediately.
## A photo that is the slide needs different fitting than a backdrop
`.wolves-intro-overlay-background` sets `object-fit: cover`. That is right for a
backdrop and wrong when the photo is the subject. Cover scales the image to fill
the frame and throws away whatever overflows, so a 3:2 stage photo in a 16:9
frame loses its top and bottom, which is exactly where a speaker's gesture and
headroom live.
`contain` fixes landscape but destroys portrait: in a tall phone viewport the
same photo shrinks to a stamp floating in black. Scope it:
- Default (portrait) keeps `cover` with `object-position` biased up the frame, so
the crop lands on the subject rather than the ceiling.
- `@media (min-aspect-ratio: 4 / 3)` switches to `contain`. That is the projector
case, and the pillarbox reads as intentional letterboxing on a dark stage.
Check both orientations. A landscape screenshot cannot show you the portrait
failure, and the portrait failure is the ugly one.
## Projected body copy is capped by measure, not by container width
The plate is as wide as the frame allows, but the text must not be. At `68rem`
of container the title card body ran to roughly 90 characters per line; an
audience tracks about 50 to 75. Cap the paragraph itself with `max-width` in
`ch` and centre it with `margin: 0 auto`, leaving the panel free to stay wide.
Add `text-wrap: balance` to the paragraph. Without it the last line collapses to
a one-word orphan, which is the most distracting artefact in projected text.
`balance` has a trap: Chromium applies it only to blocks of **six lines or
fewer** and silently falls back to normal wrapping above that. It costs nothing
and warns about nothing, so a beat that grows past six lines loses the balancing
without any visible signal in the source. If a paragraph outgrows that budget,
either split it into another beat or switch that rule to `text-wrap: pretty`,
which has no line cap but only tidies the last few lines.
Measure the result in the browser rather than trusting the CSS: divide
`getBoundingClientRect().height` by the computed `line-height` for the line
count, then divide the character count by that. Assert both the count and the
resulting characters-per-line for every beat at both orientations.
## An overlay panel can hold contrast without painting a box
A solid `background-color` plus a border plus a drop shadow reads as a lit UI
box sitting on top of the picture. To recede while staying legible, replace the
flat fill with a `radial-gradient` that falls off toward the panel edges, drop
the border and the shadow to `0`/`none`, and raise `backdrop-filter: blur()`.
Contrast then comes from the blur and the existing `text-shadow` instead of from
an opaque rectangle.
## Withhold a gallery image, do not delete it
`src/components/wolves/wallpapers-list.ts` is generated by
`scripts/generate-wallpapers.js`, which scans
`public/img/wallpapers/wolves/<subfolder>/` and turns every image it finds into
a slide. So any photo promoted to a dedicated moment, such as the opening title
card portrait, keeps appearing a second time as an anonymous gallery slide.
There are three wrong fixes and one right one:
- Hand-editing the generated list is reverted by the next generator run, and the
file's own header forbids it.
- Deleting the file breaks the dedicated slide, which loads it by path.
- Moving the file out of the scanned tree works but scatters the show's assets
across two conventions.
Use `RESERVED_FOR_DEDICATED_SLIDES` in the generator instead: a subfolder-keyed
set of filenames that `scanDirectory()` filters out. The file stays on disk for
its dedicated slide and is withheld only from the gallery. Re-run
`node scripts/generate-wallpapers.js` and confirm the slide count dropped by
exactly the number of names reserved.
Verify both halves: the entry is gone from the generated list, **and** the
dedicated slide still loads the image. Check `naturalWidth > 0` in the browser
rather than trusting the `src` attribute, because a missing file still leaves
the attribute intact.
## Allocating readability inside a locked range
- Fast music or slideshow slots must not accelerate ordinary chat typing; keep
explicitly approved dialogue cadence anchors unchanged.
- For a locked chat window, use its full player-clock duration when it exceeds
the minimum readability estimate. This retains the final sentence through
the authored endpoint instead of releasing a couch-readable chat early.
- When a narrative range is constrained, allocate chatlog readability before
static quote or source records; preserve explicitly approved cadence locks.
- Derive Track 0's rotating HUD queue directly from the authored plan and keep
duplicate status lines; deduping breaks the approved finale cadence.
## The back catalogue draws one unweighted pool, not a curated-versus-CNCF mix
The eleven album experiences in `public/experiences/catalogue.json` share a
single slide pool, ordered by `src/data/back-catalogue-order.ts`:
| Pool | Source | Approx. |
|---|---|---|
| CNCF stream | `public/flickr-photos.json` plus locally mirrored CNCF files | 667 |
| Curated | `wolves/people/` portraits and lore | 77 |
| Showcase | `wolves/showcase/` | 33 |
| Mascot art | `wolves/wolves/` | 8 |
| Hero shots | `public/characters/` via `wolves-comic-hero-shots.ts` | 23 |
Three rules, applied as independent passes:
1. **No category weighting.** Curated slides are placed into gaps between CNCF
slides, each gap equally likely. CNCF leads because it outnumbers everything
else, never because the ordering prefers it.
2. **No two non-CNCF slides adjacent**, across the combined curated set. A
screenshot followed by a dinosaur reads from the back row as "the photos
stopped", so a per-category rule is not enough.
3. **No two consecutive CNCF slides from the same event.**
Two traps live here.
**Never satisfy an ordering rule by re-drawing.** Re-shuffling until a predicate
holds is rejection sampling: it biases the distribution and silently breaks rule
1 while appearing to enforce rule 2. Place correctly by construction, then
repair what remains in a single deterministic pass.
**Concatenation order is a preference.** Gaps are filled in ascending order, so
passing the pool as portraits-then-showcase-then-mascot-then-heroes put the
first dinosaur at slide 745 of 808 — a category bias created by array order
alone, invisible in every unit test that only checked membership. Shuffle the
curated slides before placing them, and verify by asking where the first slide
of the rarest category actually lands.
## The Bluefin monthly wallpaper numbering does not match upstream, and pair 11 exists nowhere
`public/img/wallpapers/bluefin-{01..12}-{day,night}.webp` (added by `cb85d6c6`,
registered in `src/data/artwork-wallpapers.ts`) are the Bluefin monthly set
from `ublue-os/artwork` `wallpapers/bluefin/`, but the numbering has traps:
- **Local 11 ≡ local 12, byte for byte.** Both are the December mammoth scene;
pair 11 was mis-encoded as a copy of 12 at import time. Register only
01–10 and 12 — importing 11 double-books December.
- **Upstream, #11 is now "Collapse - November"** (`fix: replace 11-bluefin
with Collapse`, and `11-bluefin-day.svg` is byte-identical to
`wallpapers/collapse/collapse-day.svg`). So "bluefin-11" means the mammoths
on disk here but the dinosaur-and-asteroid Collapse artwork upstream. Verify
any claim about this set against the local files, not the upstream index.
- **The Collapse artwork is not in the wallpaper pool at all.** It lives only
in `public/wolves-intro/bluefin-collapse-{day,night}.webp`, driven by
`wolves-intro-sequence.ts`. Nothing in `wallpapers-list.ts` references it.
First-party artwork registries (`artwork-wallpapers.ts`) carry `kind`
(`artwork`/`bazzite`), pinned source commits, and licence ids, and their
`name` prefixes are what `classifyCuratedSlide()` branches on. Aurora artwork
is excluded by the owner's permission decision, not by licence: the registries
are an explicit allowlist, and `backCatalogueOrder.test.ts` asserts no record
can reference an Aurora path or the four Aurora-origin `xe_*` duplicates.
The Bazzite press kit forbids modifying artwork ("including spacing, color,
elements, and scaling"). Format conversion at identical geometry (PNG -> WebP,
3940x2160 unchanged) is the compliant reading the owner approved; CI's
`calibreapp/image-actions` recompresses added images ~25% with no dimension
loss, which is expected.
## `wolves/people/` is hand-picked, and two thirds of it is CNCF photography
`wolves/people/` is the owner's selection for the Wolves catalogue. It is **not**
a CNCF mirror, and it is not all Bluefin work either: 136 of its 213 files came
from CNCF albums and kept source-prefixed filenames (`flickr-`, `cncf-`,
`kubecon-`) or `KC+CNC_...` export titles.
So provenance cannot be inferred from the directory, and it cannot be inferred
from whether the file is served locally. Both shortcuts credit someone else's
conference photography to Bluefin. Use `classifyCuratedSlide()`, which checks
the filename stem and the title, and key the on-screen credit on the resulting
`kind`.
## Gallery captions are derived, never invented, and may be withheld
Both feeds ship filenames rather than captions: photographer exports
(`KC+CNC_EU_240319_KCS_GroupPhoto_MN_001`), camera names (`0R0A9083`,
`PXL_20240720_181225593`), and Flickr ids (`Cncf 54927603143`). All of it was
rendered verbatim, at projection size.
`src/data/gallery-captions.ts` reads back only what the filename literally
encodes — event, region, session, date. Everything else is passed through
untouched, because authored titles are already correct.
When a title encodes nothing, **render no caption**. Do not guess, and do not
fall back to a bare timestamp: "July 2024" alone describes no subject and is
noise wearing a caption's clothes. Roughly 79 of 786 titles legitimately produce
no caption.
Verify against the real feeds, not fixtures. `public/flickr-photos.json` and
`wallpapers-list.ts` between them contain grammars no fixture will suggest —
a second photographer convention (`2024-06-06_OHSNAP_...`), wordplay that a
naive camel-case split mangles (`KuberTENes` becoming `Kuber TENes`), and room
codes (`BreakoutsB206`).
## Hero shot labels name the species, ids stay filenames
`wolves-comic-hero-shots.ts` renders each `label` as the slide title in the
back-catalogue reader (`WolvesComicReader.vue` maps `title: shot.label`) and as
`alt` text in the intro overlay, so a pose-derived label like "Youre Holding It
Wrong Post 1" projects a filename onto a theater screen. Labels are the
depicted species' scientific name from `wolves-dinosaur-species.ts`, falling
back to the genus alone when no epithet is recorded in the registry or the
source filename (`Dakosaurus`, `Dromaeosaurus`). Duplicate labels across poses
are expected — the label describes the animal, not the file.
Do **not** rename the `id` fields to match: ids must stay unique for slide
identity and dedupe, and ten of the 23 shots depict *Deinonychus antirrhopus*,
so species-derived ids would collide. Ids are pinned by
`src/tests/wolvesIntroOverlay.test.ts` (full set), `tests/wolves-intro-segments.mjs`
(`youre-holding-it-wrong-post1`, `nest`), and `tests/wolves-movie-flow.mjs`.
Identifying pose-named art (`angry`, `intrigued`, `leaping`, `nest`, `pride`,
`roaring`, the "You're Holding It Wrong" bookends, the PivotRaptor commission):
all of it is the Bluefin mascot. Git history says so (`331867c3` "Add
black-outlined bluefin nest", `3e033b7b` "Resize bluefins" touching
intrigued/leaping/roaring) and a visual check against `bluefin.webp` confirms
the shared design. View the WebP directly; files that fail the viewer
(>~300 KB) can be downscaled with `dwebp <file> -scale 512 512 -o out.png` into
a scratch dir you delete afterwards.
Relabeling exposes the authored order's same-species runs — slots 16–20 are
five consecutive *D. antirrhopus*, slots 10–12 all contain *A. giganticus*, and
22→23→(wrap)→1 is an all-*D. antirrhopus* run across the loop. The id-keyed
adjacency test cannot see this. Reordering is a design decision: report it,
never reshuffle the array to fix it.
## `shuffleWolvesGalleryPhotos` is a primitive, not a diversity mechanism
It is a bare Fisher-Yates. The event-diversity logic lives in
`src/data/wolves-gallery-cycle.ts`.
This matters because it has already gone wrong once: `33a63532` shipped the
event cycle, and `255f61fb` ("retime intro and shuffle galleries") deleted the
module and pointed the call site at the shuffle. The commit subject reads like a
refactor, nothing flagged the lost guarantee, and the catalogue quietly served
long same-event runs from then on. `src/tests/wolvesGalleryCycle.test.ts` now
pins the behaviour.
The cycle spreads each event across its own stratum of the run rather than
dealing round-robin. Round-robin only behaves when events are similar sizes; the
live feed has hundreds of single-photo events, and dealing every bucket once per
round put all of them in round one.
## `/wolves/` is the teaser; the show moved to `/wolves/experience/`
`wolves/index.html` mounts `WolvesTeaserApp.vue`: hero, the recreated trailer,
and the back-catalogue album strip. The full cinematic presentation moved
as-is to `wolves/experience/index.html`, registered as the `'wolves/experience'`
rollup input in `vite.config.ts`. Moving an entry point means updating the vite
inputs and `docs/reference/wolves-runtime.md` together.
Donation surfaces were removed at the owner's direction: the org-ads strip
(`WolvesOrgAds.vue`, `wolves-org-ads.ts`, its test, the QR SVGs, and the
`generate-qrs.js` wiring) is deleted, including the mount in
`CinematicStage.vue` and its assertion in `wolvesCinematicStage.test.ts`.
`WolvesQrCodes.vue` is misnamed — it is a Chromecast launcher, not donation —
and stays.
### Recreating a trailer from the destiny-vids cut list
The source of truth is the owner-authored record
`~/src/destiny-vids/stories/trailer-1-plates.json` — video id, exact duration,
and every plate's window and copy. Port it verbatim into
`src/data/wolves-trailer-plates.ts` and pin windows and copy with
`src/tests/wolvesTrailerPlates.test.ts`. Never reword plate copy; if the cut is
recut, re-port from the record instead of editing the website copy.
The player is a chromeless YouTube iframe behind a poster overlay; plate
overlays are polled against `player.getCurrentTime()` at 100 ms and the clock
is clamped at `TRAILER_DURATION_SECONDS` with a replay affordance.
Traps, all of which already cost a debugging cycle:
- `new YT.Player(div, …)` **replaces** the host div with the iframe — style it
via `.frame :deep(iframe)` with `position: absolute; inset: 0`.
- An unresolved `var()` inside a `background` shorthand invalidates the whole
declaration at computed-value time (silently transparent poster). Set shared
image variables on a common ancestor, never on a sibling of the consumer.
- `wolves-cinematic.scss` sets the root font-size to ~10px, so `rem` widths are
~62.5% of what they read as (`78rem` ≈ 780px, not 1248px).
- YouTube rejects numeric loopback origins — test on
`http://projectbluefin.io.localhost:5173/wolves/`, not `localhost`.
- A 2.39:1 frame is ~160px tall on a phone; the poster's kicker/title stack
must collapse to the play button alone below ~640px or it overflows and
overlaps the absolutely-centred button.
Teaser album cards deep-link into the show:
`/wolves/experience/?album=<id>` makes `WolvesApp` fetch `catalogue.json`,
resolve the id, and launch that experience; an unknown id falls back to the
lobby.
### Match the trailer's authored composition, not only its copy
The plate manifest carries timing and copy; the design and the three-picture
composition live in destiny-vids' card templates and builder. Teaser work has
its own procedure: load [`../wolves-teaser/SKILL.md`](../wolves-teaser/SKILL.md).
It preserves the picture/bridge/end-card boundaries, frame-relative sizing,
letter and mark treatments, iframe geometry, and the Tailwind inline-image
trap without turning this content skill into a runtime-engineering manual.
## Wire every generated feed into the weekly refresh
`update-content.yml` refreshed Flickr photos weekly while
`update:back-catalogue` was wired into nothing, so album metadata sat at
whatever a human last ran locally. A single regeneration then pulled six track
changes and replaced a `[ Redacted ]` subtitle that had been resolved upstream
long before.
The catalogue generator shells out to `yt-dlp`, which is unreliable from CI, so
the weekly job runs `--metadata-only`: album prose and cover art over plain
`fetch`, no scraping. It exits non-zero when an upstream album is missing from
the catalogue entirely, because that genuinely needs a human with `yt-dlp`.
When adding a generated feed, check it is actually scheduled. "There is a script
for it" is not the same as "it runs".