Migration · executed overnight 2026-08-27

Hopper — moving the paint inventory into the corpus

303 entries migrated, the inventory round-trips byte-identically, and 125 descriptions are waiting for you. Six things need a person.

branches: hopper-corpus-migration in PressRaven-Corpus and Hopper, both pushed
source: Peregrine/.claude/plans/hopper-corpus-migration.md

Repos: PressRaven-Corpus (guides/hopper, guides/acrylics-field-guide), Hopper (build pipeline)

Filed here because the quick task that produced it came through Peregrine's Code Raven. Nothing in this plan touches Peregrine.


STATUS — what actually ran

StepStateResult
1 · Branchdonehopper-corpus-migration in PressRaven-Corpus and in Hopper, both pushed with upstream set
2 · Productsdone228 paint entries — 208 from inventory.source.json, 10 field-guide-only, 9 full-line adopted, 1 alias resolved
3 · Descriptionshalf103 seeded verbatim where the colour has one product; 125 remain, 53 of them deliberately empty
4 · Recipes + primersdone71 recipes, 4 primers
5 · Machine targetdoneinventory.source.json round-trips; re-export reports "identical, no change"
6 · Retire the scrapenot startedneeds step 3 finished for attachGuide; buildRecipes could go sooner

303 entries. Validator passes. tools/migrate-hopper.py is idempotent and never clobbers authored prose; tools/validate-hopper.py checks the joins no schema can.

Verified, not asserted

The export writes public/data/inventory.source.json, then npm run build:data regenerates inventory.json byte-identically — 447 paints, 189 owned. The diff against the app's committed file is five lines across 208 records: four bodyLabel: null backfills the pipeline was already computing, and one "Browns" that sorted a Liquitex record onto a shelf of its own.

What the plan got wrong

recipes.json cannot be corpus-owned yet, and the plan assumed it could. buildRecipes() in build-data.mjs regenerates it on every build by parsing the rendered field-guide HTML — recipes are scraped exactly the way descriptions are. The first export wrote the file and the next build discarded it. The machine target now declares one output. The 71 recipe entries are migrated and correct; they start shipping when step 6 deletes that function, which is a one-line change to the target.

This also splits step 6 in two: buildRecipes has no dependency on the description work and could be retired now, while attachGuide must wait for step 3.

Decisions taken overnight

Waiting for a person

  1. 125 descriptions, 53 of which need one paragraph pulled apart into two or three.
  2. Grey V5 — recipes call it Charvin, the only swatch on disk says Holbein, and it is in no inventory. Three recipes cite it.
  3. 18 unresolved recipe chips across 15 recipes: Cadmium Yellow Deep (10), Grey V5 (3), Transparent Orange (2), Zinc White, Prussian Blue.
  4. Ten paints with no consistency — the field guide names a brand but never says which body.
  5. Three near-duplicates to confirm: Cobalt Blue Deep against Cobalt Blue, Hansa Yellow Deep and Medium against Hansa Yellow.
  6. PressRaven/tools/export-machine.py is uncommitted and holds two independent sets of changes: ~306 lines of Peregrine projections that were already in the working tree, and my ~133 Hopper lines. I did not commit it rather than attribute your earlier work to a Hopper change. My half is archived at PressRaven-Corpus/tools/export-machine-hopper.py with instructions. The live file works — python3 tools/export-machine.py hopper runs today.

Why this exists

A new PressRaven project guide for Hopper exported a game-specific session packet. The cause was structural: kind is a property of the category, Game was the only kind: project category, and NewGuideSheet hides the category picker when a kind offers exactly one. "+ New Project" silently wrote category: game.

That is fixed. An app category now exists, Hopper is recategorised, and its schema is written. This plan covers only what remains: the content migration.

Already done (not part of this plan)

The decision this plan implements

Alizarin Crimson's description exists once, in guides/acrylics-field-guide, at colour grain. Hopper's inventory is at product grain — colour × brand × consistency — so Alizarin Crimson is three rows.

One grain, and it is the product. Sarah's correction, 2026-08-27: "Each paint, even with the same pigment, should have a unique description, as the formulations vary brand by brand." So there is no shared colour paragraph to inherit. Golden Open Alizarin and Liquitex Soft Body Alizarin each get their own description, and a colors surface holding shared prose would be asserting an interchangeability that does not hold.

That collapses everything a colour entry would have carried. versions was the cross-product comparison, which per-product descriptions subsume. formulations and combinations are derivable from the products and the recipes. subfamily and color_family are two-word shelf labels that cost nothing to repeat. similar_to moves to the product, because the archival substitute for Golden Open Alizarin is not automatically the substitute for the Liquitex. Three surfaces, not four: paints, recipes, primers.

The consequence worth stating plainly: guides/acrylics-field-guide stops being the source of truth. It is lifecycle: final, frozen against snapshots/acrylics-field-guide/2026-07-23. Its 144 colour paragraphs become SEEDS for 208 product descriptions rather than content that moves across intact. After this migration the field guide is retired or kept as a printed artifact of its own; it can no longer be a view over Hopper, because Hopper no longer holds colours.

This is authoring work, not a file move. That is the honest cost of the correction, and it is the largest single item in this plan.

How the content reaches the app today

Hopper/scripts/build-data.mjs → attachGuide() finds the rendered public/reference/acrylics-guide.html in the catalog, parses it with regex (parseGuidePaintEntries), matches paint names through a fuzzy guideMatchKey, and patches p.guide = {description, similarTo, versionsText} onto each inventory record. Misses are covered by a hand-maintained GUIDE_NAME_ALIASES table and logged as coverage warnings.

So corpus prose reaches the app by being printed to HTML and scraped back out. Killing that round-trip is the point of the migration, not a side effect.

Measured joins

Run against the live data on 2026-08-27.

FactCount
Field-guide colours (seeds, not entries)144
inventory.source.json products208
Colours with no product, created as unowned paints~14
Recipes71
Primers + reference in catalog.json4
Total entries to create~297
Products whose name matches a colour entry156 / 208
Products with no colour match52
Colours with no product18
Colours with more than one product21
Duplicate paragraphs avoided by colour grain32
Recipe → paint references367
References matching no inventory name71, across 8 names

The three problems the numbers expose

1. Every product needs its own description, and most do not have one.

Where a product's description comes fromProducts
Seeded 1:1 from a colour, differentiate lightly103
Seeded from a colour SHARED with siblings, must be pulled apart53, across 21 colours
No colour to seed from, written from scratch52

The worst shared colours are quinacridone magenta (5 products), titanium white (4), burnt umber (4), then green gold, burnt sienna and paynes gray at 3 each. Those 53 are where the correction bites: one paragraph has to become three or five that actually differ, and only a person who has used the tubes can write the difference.

Nine of the shared colours also disagree on pigment across their products — Golden's Alizarin Crimson is PR 83, Charvin's is PR112/PR146/PBK7; cobalt green is PG 19 vs PG 26 — so those seeds are not merely undifferentiated, they are wrong for at least one sibling. The app already shows Charvin's Alizarin wearing Golden's prose, so per-product descriptions fix a live inaccuracy rather than merely avoiding a new one.

2. Fourteen field-guide descriptions have no product to land on. Of the 18 colours with no row in inventory.source.json, only 4 exist anywhere in the built 447 (Hansa Yellow Medium, Olive Green, both Phthalo Blues, all Charvin, all unowned). The other 14 include Nickel Azo Gold and Ultramarine — the same two names the recipes reference 13 and 17 times. Under one grain these need creating as unowned paint entries rather than being dropped, which resolves the dangling recipe references in the same pass. Two are not paints at all and should be resolved rather than preserved: Cadmium Yellow Medium or Light is a compound title, Ultramarine is an alias for Ultramarine Blue.

3. Seventy-one recipe references that match no paint. Concentrated in eight names, and mostly one alias each: Titan ×27 (= Titan Buff), Ultramarine ×17 (= Ultramarine Blue), Nickel Azo Gold ×13, Titan / Unbleached Titanium ×4, Grey V5 ×3, Phthalo Blue (Green Shade) ×2. The app resolves these through GUIDE_NAME_ALIASES in build-data.mjs. The migration should resolve them once, into real references, and let the alias table die with the scrape.

Steps

  1. Branch. hopper-corpus-migration, pushed with -u on the first push. This is a wide migration writing ~297 files at once and touching a frozen guide, which is the case the branching rule exists for. Merge back as soon as step 5 verifies; delete after.
  2. Products — ~222 entries — mechanical pass. From Hopper/public/data/inventory.source.json: name→title, notes→summary, bodyLabel→paint_body, plus brand, owns, family→color_family (singularise Browns), pigment, nature, bias, opacity, intensity, uses{}, visuals from the swatch. Then create the ~14 colours that have no product as unowned entries, resolving the two that are aliases rather than paints. Every entry lands at status: seed with body empty. Write no descriptions in this step — a generated paragraph that looks authored is worse than a visibly empty field.
  3. Descriptions — 208 entries — authoring pass. Seed from the field guide where a colour matches, then differentiate. Order the work by how wrong the seed is: the 53 sharing a colour first (they are the ones a shared paragraph actively misdescribes, and the 9 pigment disagreements sit inside them), then the 52 with no seed, then the 103 that are 1:1 and mostly need a formulation sentence added. This is the step that cannot be scripted and should not be rushed to a deadline set by the other five.
  4. Recipes — 71 — and primers — 4. Independent of steps 2–3 and can start immediately. Recipes: kind→recipe_kind, category→recipe_category, subject, tier, title, intro→summary, body, paintsText→paints_text, and paints resolved to real entry titles with the 71 aliases applied — which step 2 has already made resolvable by creating Nickel Azo Gold and Ultramarine Blue. Primers from catalog.json: volume, subject, audience, palette, file, class: document.
  5. Machine target, then verify by diffing. Declare machine_target on guides/hopper/guide.json with outputs for inventory.source.json, recipes.json and the primer metadata. Export, then diff the generated files against the committed ones. A clean diff is the acceptance test — it proves the corpus reproduces the app's current content before anything downstream changes. Reconcile every difference; do not accept a diff by explaining it.
  6. Retire the scrape. Only after step 5 is clean. Delete attachGuide() and GUIDE_NAME_ALIASES from build-data.mjs: description and similarTo now arrive as real per-product fields, and versionsText has no successor — the app should render sibling products' summaries instead, which it can compute from group. Then retire guides/acrylics-field-guide, keeping its snapshot. It cannot become a view over Hopper, because Hopper no longer holds colours.

Sequencing note

Steps 2 and 4 are independent and can run in parallel; step 3 depends on step 2 and is the long pole by a wide margin. Step 5 is a hard barrier — the diff cannot be read until 2 and 4 have landed, though it does NOT wait on step 3, since an empty body diffs cleanly against an app that has no per-product description today. Step 6 must not begin until step 5 is clean, because deleting the scrape while the export is still wrong removes the only working path content has into the app.

That split matters: steps 2, 4, 5 and 6 are a few days of mechanical work that can land on their own, and step 3 is an open-ended writing project that can run for as long as it needs behind them. Treating them as one deliverable is how this stalls.

Final review

What this buys. One home for every fact, at one grain. A description now describes a tube a person can actually buy, instead of a colour abstraction that three different formulations wear badly. The regex round-trip through a rendered document dies, and with it the alias table and the coverage warnings. Recipe references become checkable. Press Raven gets a real editing surface over content that is currently hand-edited JSON.

What it costs. A frozen, finished guide loses its status as source of truth, and acrylics-field-guide is the guide with the most careful editorial history in the corpus — its vessel_notes document a recategorisation and a schema audit done specifically to make it safe to unfreeze. Roughly 297 files appear at once. The export in step 5 has to be written before any of it is provably correct.

Where this plan is weakest. Step 3 is 208 descriptions, 53 of which have to be pulled apart from a shared seed, and it needs someone who has painted with the tubes. Nothing here estimates it, because I cannot. It is not on the critical path for a working app — steps 2, 4, 5, 6 deliver that — but the guide is not finished until it is done, and a plan that leaves its largest item unestimated should say so rather than imply the migration ends at step 6.

Edge cases it does not cover. The 447-record built inventory includes 239 manufacturer full-line paints that exist only in the xlsx files and are not in inventory.source.json; this plan migrates the 208 source records and leaves the full-line merge exactly as it is. Swatch images stay in the Hopper repo and are referenced, not moved. The intensity vocabulary has nine values doing the work of about four, and locking it into the corpus unexamined would make it permanent.

Worth considering instead. Steps 3 and 4 alone — products and recipes, no colours — deliver a working project guide and leave the field guide frozen and the scrape alive. That is roughly a quarter of the work for most of the day-to-day benefit, and it keeps the hardest question (what happens to a finished guide) unanswered rather than answered under time pressure. Sarah chose the full move with the duplication cost stated, so this plan implements that; the smaller version stays available if step 2 proves as messy as the versions problem suggests.

80 / 45Confidence
schema · schedule

Confidence: 80 on the schema and the mechanical steps — settled, verified against real data, joins measured rather than assumed, and the one-grain model removes the cross-reference machinery that was the previous version's weakest part. Confidence: 45 that this plan describes how long the whole thing takes, because step 3 is a writing project wearing a migration's clothes.