Repository navigation
Decision Log
Snapshot — a copy of the repository's DECISIONS.md as of 2026-09-07. The living version is the one in the repo; if these two disagree, the repo is right and this page is stale.
The dated audit trail. An entry exists for any non-trivial decision, including decisions to not do something. Entries are never edited to look right in hindsight — a correction is appended instead.
Start here when something in the codebase does not make sense. The answer is usually already logged.
Format used throughout:
## YYYY-MM-DD — Short title
**Decided by:** <who>
**Decision:** <what was decided>
**Why:** <rationale>
**Implications:** <what changes or must be kept in mind>Decided by: @cheyras
Decision: The project is named foilkit. Packages are scoped
@foilkit/*; the repository is github.com/cheyras/foilkit.
Why: Namespace availability decided it. Re-verified against the live registries on 2026-08-30 and again on 2026-08-31:
| Name | GitHub | npm unscoped | npm @scope/core
|
|---|---|---|---|
| holofoil | taken (200) | free | free |
| holoforge | taken (200) | free | free |
| foilkit | free (404) | free (404) | free (404) |
| chromafoil | free | free | free |
holofoil was the preferred name and is unavailable on GitHub: the handle
belongs to an empty personal account — 0 repositories, 0 stars, no bio — and
squatter-release through GitHub's name-reclamation process is slow and
unreliable. A name that is free on npm but taken on GitHub is not free; the
repository is where contributors actually arrive.
mylar was ruled out regardless of availability — it is a registered trademark
of DuPont Teijin Films.
Implications: Package names, the repository, the hosted subdomain
(foilkit.deckpal.app) and every SPDX header follow from this. The npm
namespace is not yet reserved (see the pending item below), which is the one
part of this decision with a live clock on it.
Decided by: @cheyras
Decision: foilkit is github.com/cheyras/foilkit, a repository under the
author's personal account. No foilkit GitHub organisation is created.
Why: The original plan reserved a GitHub org as the trust anchor. It was
dropped as premature. An organisation adds an administrative surface — seats,
roles, org-level settings, an admin:org token scope the working credential
does not currently carry — in exchange for a signal that matters only once there
are multiple maintainers, which there are not. A single-author project under a
personal account is the honest shape of the thing today.
Implications:
- The GitHub namespace
foilkitremains unclaimed. That is a deliberate accepted risk, not an oversight; if it is later taken by someone else the repository keeps working and only the vanity URL is lost. - Transferring
cheyras/foilkitinto an org later is a supported, one-click operation that preserves issues, pull requests, stars and redirects. Choosing the org now would not have been reversible in the same easy direction. - The planned GitHub App (contribution automation) is scoped to a single repository either way, so nothing downstream depends on the org existing.
Decided by: @cheyras
Decision: Code under packages/ is MIT. The dataset — data/**, every
*.canon.json, resolver tables, mask images — is dedicated to the public domain
under CC0-1.0. Documentation is MIT. The split is declared machine-readably
in REUSE.toml and, for files that can carry a comment, in per-file
SPDX-License-Identifier headers.
Why: The stated preference was CC0 across the board, and the data is CC0 for exactly that reason — it is measurement of the physical world, and the project's thesis is that anyone can take the whole corpus and do anything with it.
The code is MIT anyway, for one specific reason: CC0 contains no patent grant and explicitly disclaims one. The FSF flags this, and a number of corporate legal teams carry a blanket block on CC0-licensed software. foilkit's entire thesis is that somebody drops it into a storefront or a collection app — MIT clears legal review essentially everywhere, CC0 sometimes does not. MIT's only cost over CC0 is a LICENSE file nobody reads: no real obligation, no attribution burden on adopters.
Documentation is MIT rather than CC-BY, deliberately. CC-BY's attribution requirement is a stronger obligation on a downstream reuser than MIT's notice-retention, and it would put a third license in anyone's SBOM for no gain. Two licenses is the number a legal reviewer wants to see.
Implications:
- The boundary splits files, not packages.
@foilkit/patternswill contain both:patterns/*.glslis MIT,patterns/*.canon.jsonis CC0.
- Every
.ts/.js/.mjs/.glslfile must carry// SPDX-License-Identifier: MIT. This is new convention, not a port — the origin repository has zero SPDX headers anywhere.
- JSON and PNG cannot carry a comment, so
REUSE.toml's globs are the only machine-readable statement of their license. Removing a glob silently un-licenses a corpus. - Data contributions need an explicit CC0 dedication, not merely a DCO sign-off — see the contributor-terms entry below.
Decided by: @cheyras
Decision: All eight planned packages — core, three, webgl2, element,
react, patterns, resolver, masks — live in a single repository as a pnpm
workspace under packages/*.
Why: The deciding reason is the planned contribution App, which is scoped
to one repository. A pattern contribution touching @foilkit/patterns
alongside its canon file touching @foilkit/core would otherwise need
multi-repository scope and two coordinated pull requests — for what is, to the
contributor, one measurement.
Secondarily: the dataset and the renderer version together. A canon file is a full snapshot rather than a delta, so a split that let them drift would let them drift silently.
Implications: Splitting a package out later is easy. Merging repositories later is not. Release tooling has to handle per-package versioning inside one tag namespace.
Decided by: @cheyras
Decision: Code contributions require a DCO sign-off. Contributions touching
data/ or any *.canon.json require a sign-off and an explicit CC0
dedication line in the pull-request body, carried today by a required checkbox
in .github/PULL_REQUEST_TEMPLATE.md.
Why: A DCO sign-off certifies that you wrote the contribution or have the right to submit it under the project's license. It does not perform a public-domain dedication. CC0 is an affirmative act of abandonment; nothing in the DCO performs it. A contributor who signs off and adds a canon file has not dedicated it, and that hole in the dataset's public-domain claim is very hard to close retroactively — it means finding the person, years later, and asking.
Alternatives considered and rejected: a full CLA (heavyweight, kills drive-by contributions, wrong for a measurement corpus); relying on the DCO alone (leaves the hole); a dedication statement written by hand into every PR body, the SQLite/curl posture (works, but nothing prompts for it).
Implications: The planned GitHub App composes pull requests directly and will insert the dedication line into the PR body itself, so a contributor's approval of their own PR is their assent — nearly free, and it closes the gap at the moment of contribution. Until then the template is the fallback and is not optional. The template stays after the App ships, for pull requests opened by hand through the GitHub UI.
Decided by: @cheyras
Decision: Ship nothing we do not own outright. Third-party material enters
by source and reference, never by copy. Any original asset authored to fill a
gap left by a rejected third-party one carries a notice file recording the
investigation, the rejections and their reasons, and the authorship. The shape
is specified in NOTICE-CONVENTIONS.md.
Why: A CC0 dedication is only worth what the dedicator's standing is worth. A traced trademark glyph or a vendored scan inside a public-domain corpus does not become public domain by being in it — it makes the whole dedication a claim nobody can rely on. The rule also has to survive a scanner and a legal reviewer, not just a good-faith reader.
Implications: No card artwork, scans, logos, game rips or traced marks in
data/, in demos or in test fixtures. Reference imagery is cited plus a fetch
procedure, not vendored. Every asset carries its notice, and the notice records
what was rejected — a rejection trail is the part that proves the
investigation happened.
Decided by: @cheyras
Decision: RELICENSE.md cites foilkit's own initial commit, the offline
mirror deckpal-mirror-2026-08-31.git, and the DeckPal wiki page
Foil-Branch-Log. It cites no live SHA on any DeckPal foil/* branch, and
no tag was created to preserve one.
Why: Those branches are scheduled for deletion, which is what removes the third-party reference media from the origin repository. A tag pointing at a branch tip keeps the deleted objects reachable — it would defeat the deletion entirely. The mirror and the wiki page are the archive.
Implications: The authorship figure is the re-measured one: 465 commits,
76,291 words, 100% authored by cheyras <cheyras@gmail.com>, zero co-authors,
measured 2026-08-31 against the mirror. The earlier working figure of 199
commits was counted against a single branch at an earlier date, was never
re-verified, and should not be cited anywhere.
Decided by: Claude Fable 5, on behalf of @cheyras
Decision: foilkit's seven wiki pages were written and committed to
wiki-staging/ in this repository rather than pushed to the wiki. They move to
the wiki, and wiki-staging/ is deleted, in a single follow-up commit once the
maintainer has created the wiki's first page.
Why: GitHub does not accept a push to an uninitialized wiki. The feature is
enabled on this repository, but until a person creates the first page through the
web UI the wiki's git remote does not exist — cloning and pushing both fail
with Repository not found. Verified 2026-08-31. There is no API, no CLI command
and no token scope that initializes it; it is browser-only, and the working
credential could not have done it regardless of scope.
The alternative was to hold the pages until the click happened, which risks them being written twice or not at all. Staging them costs one directory and one follow-up commit.
Implications:
- Every
…/wiki/…link inREADME.md,AGENTS.mdanddocs/README.md404s until the click. They were written against the final URLs deliberately, so nothing needs rewriting afterward. -
wiki-staging/README.mdcarries the exact steps for both halves. -
Delete
wiki-staging/in the same commit that records the wiki going live. Two copies of a document with nothing keeping them in sync is worse than one.
Recorded here so they are not mistaken for settled:
-
npm namespace unreserved.
foilkit,@foilkit/coreand@foilkit/patternswere all free on the registry as of 2026-08-31, but the working machine is not authenticated to npm andnpm loginis an interactive browser flow. The npm organisation and the placeholder unscopedfoilkitpackage (a 0.0.0 stub pointing at@foilkit/core, so that nobody ships a confusable package under the obvious bare name) both remain to be created. This is the item with a real clock on it. -
Hosting.
foilkit.deckpal.apphas no Vercel project and no deployment. DNS needs nothing —deckpal.appalready carries a wildcard ALIAS at Vercel, so the subdomain resolves today and returnsDEPLOYMENT_NOT_FOUND. Standing it up is a Vercel project plus a domain assignment, both infrastructure writes requiring the maintainer's explicit approval. -
The wiki is not initialized. Seven pages are written and staged in
wiki-staging/; they cannot be pushed until the maintainer clicks "Create the first page". See the entry above. -
The extraction itself. No library code, no dataset and no
packages/directory exist in this repository yet.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: tools/rectifier/ implements the missing half of the scan
pipeline — a detected quad plus its image becomes a canonical raster and the
3 × 3 row-major homography that produced it — together with the pair-diff and
three-way null / frame / full classifier that task 3b's reverse-holo
delta measurement runs on. Zero dependencies: pure-maths DLT, hand-rolled
bilinear warp, PNG over node:zlib, tests on node:test.
Why: 3b cannot start without it. DeckPal's dev/scan-harness is a
detector — it returns corner lists and warps nothing — so every pair would
otherwise be hand-aligned, which 3b's verification list forbids in as many
words. Building it now also front-loads task 4's constants module and task 14's
capture front end, both of which want exactly this code.
Implications:
-
No build step and no dev dependency. Node 22.18+/24 strips TypeScript
natively, so the suite runs as
node --test "tools/rectifier/*.test.ts". Atsxloader was considered and is not needed. No rootpackage.jsonwas added: the pnpm workspace is the extraction's decision to make, not this task's, and a README line documents the command in the meantime. -
constants.tsis task 4b's constants module, early. The millimetre constants and the "3 mm corner is triangulated, not official, credible range 2.5–3.0 mm" provenance are carried from DeckPal'scardGeometry.ts. Nothing derived is typed in twice, andconstants.test.tsreads the source back and fails on a numeric literal assigned to a derived export — 4b's verification item, satisfied by construction rather than by discipline. - Round-trip error is measured, and the synthetic number is not the real number. Synthetic smooth pattern through a keystoned quad: mean 0.0019/255, max 1/255. The same path with a real card scan: mean 2.28/255, max 54/255 (n = 1). Real cards are hard edges and 6 pt type; two bilinear resamples cost real error at every one of them. Anything downstream that keys on a single pixel's value budgets against the real figure.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: The real-scan smoke test fetches TCGdex's high.png rather than
high.webp. The scans are downloaded by tools/rectifier/fetch-smoke-scans.ts
into the gitignored reference-media/ tree and the test skips when they are
absent.
Why: Decoding WebP with no dependency means writing a VP8 intra decoder — a
real project, and one with nothing to do with homographies. TCGdex serves the
same asset as .png from the same path, and png.ts already decodes PNG.
Nothing in the rectifier is format-aware; it takes an RGBA buffer, so swapping
in a WebP decoder later changes only the loader. Fetch-not-vendor is F2: the
citation ships, the pixels do not.
Implications: smoke-scans.ts is the citation and ships; the images never
do. A clone with no network passes its test run with the smoke suite skipped,
which is honest, where a vendored card scan would be a licence problem.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: tools/rectifier/png.ts extends the codec ported from DeckPal
with colour type 3 (palette, bit depths 1/2/4/8, PLTE + tRNS) and Adam7
de-interlacing.
Why: DeckPal's codec had only ever read canvas.toDataURL output, so it
supported neither. The first two real catalog scans this rectifier was pointed
at — TCGdex high.png — are 8-bit palettised and Adam7-interlaced, and the
original threw interlaced PNG unsupported on both.
Implications: Recorded because it is the smoke test earning its place on the first run: the synthetic suite would never have found this, and task 4a's framing census walks the same catalog rasters and would have hit it later and further from the cause. 16-bit still throws, deliberately.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: CHANGED_PIXEL_DELTA (24), NULL_MAX_CHANGED_FRACTION (0.005),
FRAME_MAX_INSIDE_CHANGED_FRACTION (0.02), EDGE_MARGIN_PX (4) and
MAX_RESIDUAL_SHIFT_PX (4) ship as named constants whose comments state they
are provisional and have never seen a photographed pair.
Why: F5 — a measurement carries its n, and this one's n is zero. The values separate the synthetic cases with room to spare and that is the entire claim being made for them. Publishing a bare number would have let a later reader mistake a placeholder for a finding.
Implications:
- 3b's measurement is blocked on the physical pair capture and on nothing else. No bulk source ships variant-specific imagery, so both printings of a card have to be shot from the binder.
-
PLACEHOLDER_ART_WINDOWis explicitly not a measurement — the real windows are per-era inera-layouts.json. A result computed against the placeholder should say so, andclassifyDeltarefuses to separateframefromfullat all when no window is supplied, returning the conservative class withtrustworthy: false. - The alignment guard likewise reports rather than corrects: a pair whose two rectifications disagree is re-detected, never nudged.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: The pending item reading "No library code, no dataset and no
packages/ directory exist in this repository yet" is now partly out of date
and is corrected here rather than edited in place.
Why: tools/rectifier/ is working code, checked in on this date. It is
deliberately not under packages/: it predates the extraction, it is a tool
rather than a published package, and constants.ts is the only part of it with
a settled destination (@foilkit/core, per task 4b).
Implications: packages/ and the dataset remain empty and the extraction is
still outstanding. The rest of that pending entry stands unchanged.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: The foil runtime, authoring stack and corpus were extracted from
DeckPal's foil/main (b803b43) into
packages/{core,patterns,three,resolver,forge}, data/, docs/ and
reference/. RECEIPT.md records the evidence that nothing changed in transit;
its four measurements are 45/45 byte-identical renders, 177 passing tests, an
identical resolver digest over 10,312 probes, and core compiling and running
with three.js absent from node_modules.
Why: The origin branches are scheduled for deletion, and a move is only safely reversible-by-inspection if it arrives with proof. The receipt is a one-time artifact and says so: once read, the harness goes back to being a tool rather than a gate.
Implications: The pending item above — "packages/ and the dataset remain
empty" — is now closed. Nothing is published: every package is 0.0.0 and no
release has been cut.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: packages/core/src/card-space.json (the datum) and
canonical-space.ts (the derivation) are the single definition of canonical
card space. tools/rectifier/constants.ts is now a re-export from
@foilkit/core, and its contract test follows the definition rather than the
shim.
Why: DeckPal carried two copies of the same arithmetic because its api
tsconfig.json pinned rootDir and could not import its web sources; the
rectifier, written here before the extraction, was a third. Its own header
predicted this move. Three copies of an expression are three chances to
disagree.
Implications: The rectifier's CARD_ASPECT is width/height and the shader's
is height/width, so the shim maps them explicitly (CARD_ASPECT_WH /
CARD_ASPECT_HW) rather than star-exporting — a silent swap there is a 1.95×
error nobody would notice until a mask was cut wrong.
Still duplicated, and recorded as such: tools/rectifier/png.ts and
packages/forge/src/png.ts are two hand-rolled PNG codecs over node:zlib.
Both work, both are tested, and unifying them would touch the rectifier's own
proofs. A follow-up, not a blocker.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: @foilkit/core's CARD_ASPECT is CARD_ASPECT_HW, computed from
the millimetre datum. It previously read era-layouts.json's cardAspect.
Why: era-layouts.json belongs to @foilkit/resolver, which is
Pokémon-specific and optional by construction — someone rendering Magic cards
wants the shader and not the resolver. Core reading it would have made the
optional package mandatory.
Implications: Identical to every decimal the shader emits (1.39683,
0.0476), which the render receipt confirms at the byte level. The layout
contract test still asserts that era-layouts.json agrees with the expression,
so the two cannot drift apart silently.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: The glyph asset route, the artwork-citation URL a sidecar records,
and the mask route a corpus report names are now configurable, each defaulting
to a relative path a static host satisfies for free. DeckPal's @deckscout/db
import became an injectable pool (registerAssetPool).
Why: A hardcoded host prefix in an extracted library means nothing anywhere
else, and depending on a database this repository does not have would break
@foilkit/forge's "node: builtins only" property — which is what lets it run
with no install and no build step.
Implications: Nothing registered means nothing to look up, which is exactly
the pre-existing unreachable-database path: a fallback, not a crash. The
sidecar's artworkUrl stays a CITATION and never an asset; the pixels are never
carried.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: tools/build-pattern-cards.mts resolves pg at run time out of
whichever project supplies the catalog (PG_HOST_PACKAGE), rather than foilkit
depending on it.
Why: That tool INVERTS the resolver — it walks every printing in a catalog and records which ones each pattern governs. foilkit ships measurements of printings, not a list of them, and will not grow a database to keep one tool happy.
Implications: The tool still runs in both offline modes
(--evidence-only, --fixture) with no catalog at all, which is what CI and a
contributor without one use. Subtask 7 turns this into a build-time input
question, because the hosted editor has no database at read time.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: The auto-caption block embedded in 39 of the 44
reference/pipeline/jobs/*.json prompts was removed, alongside the transcript
sections in all 44 notes.md and the inline quotes in the 4 gemini-spec.md
files the content rules named.
Why: The rules named the notes and the four specs. They did not name the job files — but the job files carried considerably MORE transcript than the notes did, and the rule's reason (the creator's words are the creator's, exactly as the frames are) covers them exactly. Following the letter would have missed the larger half.
Implications: Each prompt now carries a note saying what was there and that
fetch-reference.sh writes it back locally, from a source the operator fetched
themselves. The prompt rubric — the part that is ours — is untouched, so the
pipeline stays reproducible for anyone who has the media.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: tools/parity/ takes its shader, material, pattern and canon
sources as URL parameters. The moving receipt was measured by pointing the same
page at foilkit's packages/*/dist and at a plain tsc build of DeckPal's
shader.ts + patterns.ts.
Why: The plan called for running DeckPal's harness in DeckPal and foilkit's in foilkit. Those are two different pages, and a byte difference between them would have been unattributable — different chrome, different backdrop, different card rect. Holding the page fixed makes the code the only variable, which is the claim the receipt is trying to support.
Implications: A deviation from the plan, and a strengthening one. It also leaves a general instrument rather than a one-off: the same harness re-runs the canon recheck under a future contract bump (one run per law, compared on mean absolute error), and it is the seed of subtask 6's stress demo.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: All 32 canon files now record every contract uniform and every
declared param explicitly, at the value already in effect. 22 widened — the 21
saved on 2026-08-02, plus tinsel-ii, which lacked only uP4. Ten were already
full. tunedUnderContract was not touched.
Why: A canon file claims to be a full snapshot. Twenty-one inherited the
eight R4–R6 composite dials from GLOBAL_DEFAULTS at read time, which is a
promise to track a code constant — harmless while the constant lived next door,
not harmless once it lives in a separately versioned package.
Implications: Recording an inherited default is not a tuning decision, so
those 21 are still tunedUnderContract: 1 and still in the retune queue. The
zero-delta harness re-ran 45/45 byte-identical after the freeze, which is the
only acceptable evidence for a "nothing changed visually" claim. Undeclared
params and uScanBase are not written, for the same reason: writing them would
assert decisions nobody made.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: FoilLab.tsx, CanonLab.tsx, MaskProvenance.tsx, ui.tsx and
the api.ts HTTP client stayed in DeckPal. The coupling-free view code —
CardViewer, useTilt, ViewTransform, MaskEditor, WindowEditor — came,
under @foilkit/three/react.
Why: The extraction plan's package map named buildFoilMaterial,
CardViewer and useTilt for @foilkit/three and nothing else. The lab shell
is bound to DeckPal's router, its query client and its API prefix; the hosted
contribution editor is its replacement, not its port.
Implications: This is a real loss if the origin branches are deleted before anyone decides otherwise — roughly 2,700 lines of working editor UI, including the canon lab's slider layout and the provenance panel. Flagged for the maintainer rather than settled here. The drawing surfaces subtask 8 would rebuild around are the part that came, which was the expensive half.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: Five things, all of them consequences of one rule — data/** is
CC0, and a CC0 dedication can only cover material this project has standing to
dedicate.
1. The quote scrub. data/foil-card-assignments.json and
data/foil-pattern-usage.json carried 162 YouTube-attributed
sources[].quote strings, ~2,517 words, of which 127 were not marked as
paraphrase, the longest verbatim run was 39 words, and one was labelled
"cleaned auto-caption" in the file itself. Every one of the 162 has been
rewritten as a short paraphrase in the maintainer's voice, prefixed
Paraphrase (…), with the factual claim preserved — set names, pattern
identifications, physical descriptions, demo cards — and the attribution
normalised to video id (the url) plus chapter and timestamp. 162 of 162 are
now marked; 0 unmarked. Where a timestamp was never recorded the text says so
rather than inventing one. Both files also carry a $comment stating the policy,
so the next contributor reads the rule before adding a row.
2. The caption write-back that did not exist. reference/README.md and all
39 pipeline/jobs/*.json prompts claimed fetch-reference.sh "writes the
caption block back into this slot". No such capability existed. Rather than
delete the claim, it was made true and honest: the docs now say the captions
were removed from the repository because they are third-party narration, and
fetch-reference.sh --captions (--captions-only to skip the video cut) slices
each pattern's chapter range out of yt-dlp's auto-subtitles into
reference-media/.captions/<slug>.txt, which the existing reference-media/
gitignore rule already covers. Each of the 39 jobs names that path in a new
captionsFile field; pipeline/gemini_vision.py appends the text when the file
is present and runs on the frames alone when it is not. RECEIPT.md carries a
dated correction of the same claim.
3. The pkmn.gg citations stay. Ten sources[].url entries in
data/foil-card-assignments.json (and the ten hostnames the derived index
keeps) point at pkmn.gg. They are kept. pkmn.gg was ruled out as an ASSET
source — we do not ship its images or its data — and that ruling says nothing
about citing it. These are provenance records of where an observation came from:
nominative use of the name, a URL, and our own restatement of what it showed.
Deleting them would make the corpus less checkable while removing nothing that
was ever copied.
4. The usage index no longer knows a host. tools/build-usage-index.mjs
hard-coded http://127.0.0.1:3712/deckscout/api for its optional coverage
report — a DeckPal address, in a repository with no catalog. The URL is now an
opt-in --api <base-url> flag or FOILKIT_CATALOG_API env var with no default
baked in; with neither, the report is skipped cleanly and the output file is
unaffected, as it always was. Both index builders also had stale extraction
paths (../../research/* → apps/web/src/foil/*) and could not run at all;
they now read data/ and write packages/resolver/src/. Regenerating with the
fixed paths reproduces both committed indexes byte for byte, which is the
evidence that the path fix is right and that no quote text ever reached them.
reference/fetch-reference.sh additionally had a latent set -u failure —
local id="$1" out="…$id…" aborts under bash 5.2 — fixed in the same pass.
5. Verification. The resolver digest probe re-ran over the same 10,312
inputs: 8541aa1b389d5a8b04508aa237189dce196ef470f358dc913c8c9584beafc367,
identical to the receipt, and the two output files diff clean line for line —
the scrub moved no guess. 177/177 tests pass, reuse lint clean.
Why: Third-party narration in a CC0 file is the one defect that makes the dedication itself false, and the dataset is the half of this project that other people are meant to be able to take. The evidentiary value of a citation is the claim plus the pointer, not the wording; paraphrase keeps all of the first and loses none of the second.
Implications: These were the last open items before DeckPal's foil/*
branches are deleted. Four verification footnotes belong with them, because each
one looks like a discrepancy until it is explained:
- The contract stamp is 2, not 4. The extraction spec says canon files stamp contract 4; revision 4b renumbered the contract sequence, and 2 is the value that survived. The spec's "4" is superseded, not violated.
-
foil/tooling-researchwas a path SUBSET offoil/main. Nothing was lost by extracting fromfoil/mainalone; there was no second corpus to merge. -
The literal string "Transcript excerpt" appears in two mid-history commit
READMEs. It is a reference to the section that was stripped, not the
section's content.
git grep -ci transcriptat HEAD hits four files, all of them meta. -
MANIFEST.json's frame check is width-only. Heights were verified by hand this round: 344/344 frames at 270 px, matching the recorded 480 × 270.
Flagged, not fixed — for the maintainer. The scan that closed item 1 also looked at the 340 non-YouTube quotes in the same two files, and they have the same shape of exposure: the longest is 83 words from insights.collexy.com, with 177 Bulbapedia quotes (longest 56 words), 14 PokeBeach (58) and 9 BleedingCool (34). Bulbapedia is CC-BY-SA and Collexy reserves its rights; neither is CC0-dedicatable by us. This closeout was scoped to the YouTube narration and deliberately did not widen itself. It is the obvious next scrub, and it is recorded here so that deleting the origin branches does not delete the knowledge that it is outstanding.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: The follow-up scrub named in the previous entry has been done. The
340 non-YouTube sources[].quote strings in
data/foil-card-assignments.json and data/foil-pattern-usage.json — 9,632
words, none of them marked — are now 340 paraphrases in the maintainer's
voice, 6,984 words, all 340 marked. Combined with the 162 video citations the
closeout rewrote, all 502 quote strings in both files are marked paraphrase
and 0 are unmarked, from any source.
| Source | Quotes | Longest original |
|---|---|---|
| Bulbapedia (CC-BY-SA 2.5) | 177 | 56 w |
| Collexy (all rights reserved) | 124 | 83 w |
| PokeBeach | 14 | 58 w |
| pkmn.gg | 10 | 30 w |
| Bleeding Cool | 9 | 34 w |
| Sleeve No Card Behind | 6 | 22 w |
| Total | 340 | 83 w |
Attribution was normalised the way the video citations were, to the same
Paraphrase (…) — form: publication name plus the page title the URL already
names, with the url field unchanged beside it — Paraphrase (Bulbapedia, 'Holofoil') — …, Paraphrase (Collexy, 'XY era holofoil overview') — …,
Paraphrase (pkmn.gg, 'Silver Tempest Trainer Gallery' set list) — …. Nothing
was invented to fill a field: none of these URLs carries a section anchor, so
none of the citations claims one, exactly as the video citations say "timestamp
not recorded" rather than guessing a timestamp. Both files' $comment policy
note now covers every quote rather than only the video ones, and says why.
Why: The closeout's rule has not changed — data/** is CC0, and a CC0
dedication can only cover material this project has standing to dedicate. What
changed is the recognition that the written sources are the worse half of the
problem, not the lesser one:
- Bulbapedia is CC-BY-SA 2.5. That is not merely "not ours to dedicate", it is a direct licence conflict inside a CC0-declared file: CC-BY-SA is copyleft, it requires attribution and share-alike on any reuse, and CC0 is a waiver of exactly those conditions. 177 CC-BY-SA strings sitting under a CC0 notice made the notice false about a third of the file's citations, and would have propagated a share-alike obligation to every downstream user who took the dataset at its word. A paraphrase of a fact carries no such obligation: CC-BY-SA covers the expression, and the facts about which foil pattern a set used are not copyrightable in the first place.
- Collexy, PokeBeach, Bleeding Cool and Sleeve No Card Behind reserve their rights outright. The 83-word Collexy passage was the single largest block of third-party prose anywhere in the repository, larger than any video quote the closeout removed.
- pkmn.gg stays cited, for the reason given last time. Its ten entries were already the project's own restatements of set-list counts; they are now marked and attributed like the rest. Citing a source is not copying it.
Implications and verification.
-
The guesses did not move. The resolver digest probe re-ran over the same
10,312 inputs and returned
8541aa1b389d5a8b04508aa237189dce196ef470f358dc913c8c9584beafc367— identical toRECEIPT.mdand to both receipt files, with the same tier split (4,160facet, 2,664set, 2,520series, 880card, 88heuristic) andRESOLVER_VERSION5. Citation text is evidence for a row, never an input to it, and the digest is what proves that rather than asserting it. -
Both derived indexes regenerate byte-identically.
tools/build-assignments-index.mjs(125 rows, 21 facets) andtools/build-usage-index.mjs(122 rows) produce no diff at all — not even a citation-text change, because neither index carries quote text, only hostnames. -
177/177 tests pass;
reuse lintis clean (575/575 files carry copyright and licence information; CC0-1.0 and MIT, no bad or deprecated licences). - Longest remaining verbatim-looking run: 19 words, and it is a list of Pokémon names — "Ho-Oh, Lugia, Pikachu, Wobbuffet, Hoothoot, Noctowl, Feraligatr, Meganium, Typhlosion, Latias, Latios, Cleffa, Smoochum, Shuckle, Raikou, Entei, Suicune, Porygon" — which is the enumerated fact itself and is not expression anyone can own. The next two are set names in a list (11 and 10 words, both "Wizards Black Star Promos, Southern Islands and Best of Game"). The longest run containing any connective prose at all is 8 words — "Raichu came in the Supreme Victors Value Pack", five of them proper nouns. Mean shared run across all 340 is 4.4 words. The longest paraphrase in the file is 46 words, against an 83-word longest original.
This closes the item the previous entry flagged. Nothing about the source-quote policy is now outstanding in either evidence file.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: "One renderer, any number of cards" ships as @foilkit/stage
(the budget ladder, the six tilt sources, the frame schedule, the scissor
arithmetic, the texture sizing — importing nothing) plus FoilStage inside
@foilkit/three (one canvas, one WebGLRenderer, a material cache keyed by
patternId, a texture cache keyed by URL, one rAF loop, one
IntersectionObserver, one ResizeObserver).
Why: folding all of it into @foilkit/three was the obvious alternative and
was refused for a concrete reason, not an aesthetic one. A packages/stage that
depended on @foilkit/three would have been imported back by
three/react/CardViewer, and TypeScript project references forbid the cycle;
breaking the cycle by moving CardViewer into the new package would have
changed its import path, which is a public shape this work promised to keep.
Splitting policy from binding has no cycle in either direction, and it buys
something real: the ladder and the sources are arithmetic over rectangles and
frame times, so they are tested by node --test with no GPU (40 new tests), and
a future webgl2 or element adapter inherits the architecture instead of
reimplementing it. tools/check-independence.mjs now proves stage imports no
renderer, exactly as it does of core.
Implications:
-
CardViewerkeeps every prop it had and becomes a thin host registering one element, inblitmode so its overlays and pan/zoom wrapper are untouched.useTiltkeeps its public shape and delegates the mapping to the stage's sources. -
options.rendereris honoured, never restyled and never disposed: a host already running a three.js scene must not be forced into a second context. -
renderScaleis a no-op inunderlay. A card's pixels there ARE the page's pixels and cannot be scaled up after the fact. It is live inblit, where a card is rendered small and stretched on the way out. Rung 1 therefore has one step fewer in underlay, and the ladder moves on to rung 2 rather than pretending otherwise. -
Frozen (rung 3) and stopped (rung 4) cards are still DRAWN in
underlay. Freezing means the image stops changing, which is what the rung is for; it saves the per-card CPU work and not the fragment shading. Inblita frozen card skips its render and its blit entirely, so it saves both.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: The budget ladder's input is the stage's own work time — how
long its frame callback took — compared against the budget of the cadence
currently being run when deciding to drop, and against the TARGET cadence when
deciding to climb. blit reads the shared drawing buffer with drawImage
inside the same frame, and the renderer is created without
preserveDrawingBuffer.
Why: the obvious signal, the wall-clock gap between frames, is self-defeating: rung 2 lengthens that gap deliberately, so a ladder reading its own cadence as evidence of load can drop but can never climb back. Judging drops and climbs against different budgets is what lets a stage sit correctly at 30fps without either oscillating or getting stuck there.
preserveDrawingBuffer: true was considered for a different design of rungs 3–4
— clear only the dirty card rects and let frozen cards' pixels persist, which
would have saved fragment shading in underlay too. Refused: it costs a
driver-dependent full-frame copy every frame, and it breaks the moment the page
scrolls, because a persisted rect is in the wrong place as soon as its element
moves. Reading the buffer inside the same task needs no such flag.
Implications: the ladder can be exercised deterministically by adding
measurable work rather than by finding a slow machine — stage.syntheticLoadMs
busy-waits inside the measured region, the demo exposes it as a slider, and the
acceptance test uses it. That is the only honest way to assert "engages and
recovers" in CI, since the ladder reads work time and has no idea where the work
came from. Measured on this run: forced 26ms of work per frame took the ladder
from step 0 to step 7 (rung 2, "30 fps") and it returned to step 0 within ~15s
of the load being removed.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: apps/demo draws its card faces in a canvas from a seeded
generator. No scan, no fetch of one, no fixture. What is real in it is the
shipped recipes, their canon snapshots read from data/foil-canon, the shipped
composite and the shipped stage.
Why: F2 (source and reference, never copy) is written to cover data/, a
demo and a test fixture in the same breath, and there is nothing to trade away
here: the texture budget is a function of SIZE and COUNT, and the generated
faces are the same size and count real scans would be. A blank base also
exercises the classic composite path (uScanBase: 0), which is the one the
canon files were tuned under.
Implications: the demo is runnable by anyone who clones the repository, with no credentials, no image proxy and no network beyond the local static server — which is also what makes it viable as a CI gate. The acceptance job runs it in headless Chromium under SwiftShader as a separate workflow job, so a contributor's unit-test failure is not queued behind a browser download.
2026-09-01 — Vector masks rasterise at the size the stage chose, and the common tier uploads nothing
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: The stage takes a maskVector — a rasteriser it calls with a
width and height IT picked from the card's on-screen box — rather than a stored
raster. Masks get half the face budget and a 512px ceiling
(maskTextureWidth). Cards sharing a maskVectorId share one rasterisation per
size. Where there is neither a hand mask nor a vector one, the layout-rect tier
renders with no texture at all.
Why: uMaskTex is low-frequency alpha — the feather is 0.008 UV, several
pixels wide at any plausible size — so a mask matched to face resolution buys
detail the shader immediately blurs away. And a mask stored as geometry has no
size to be wrong: the stage measures the box, picks a width, and asks for it, so
"which raster size do I store" is a question that never gets asked. The
signature is deliberately the one @foilkit/forge's rasterizeTemplate(tpl, w, h, opts) already has, so the authoring stack plugs in unchanged, and
rasterizeSvgMask(d) covers the case where a host has only a path string —
Path2D speaks SVG path syntax natively, arcs included.
The stage does NOT depend on @foilkit/forge to do this. Forge reaches for
node:fs in several modules, and a browser-side stage importing it would drag
the authoring stack into every consumer's bundle. Passing the rasteriser in is
one function and no dependency.
Implications: measured in the demo — 42 mounted cards, one third of them
vector-masked, produce one raster. The other two thirds upload nothing,
which is the reason three hundred cards hold six textures rather than three
hundred and six. Changing a template's geometry requires changing its
maskVectorId; the cache key is the id and the size, not the function
identity.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: After probeAfterMs (default 5s) sitting in the dead band — under
budget, but without the clear headroom a fast climb needs — the ladder steps up
ONE rung to see whether that rung holds. A probe undone by a drop within
probeGraceMs counts as refused and the probe interval doubles, to a 60s
ceiling; clear headroom resets it.
Why: CI found this, on the same run as the pixel-ratio bug, and it is the more interesting of the two. The GitHub runner's resting frame work landed inside the dead band. That made it stable at step 0 — correctly, nothing was over budget — but it also made it stable at step 5 after a transient, because climbing required headroom the machine never had. The run recovered 8 → 5 and stopped, and a page in that state is quietly worse until it reloads. "It climbs back up as headroom returns" cannot be true if the ladder is a one-way door: a stall becomes indistinguishable from a correct answer.
The dead band's width is what makes this bite. It is 0.6 to 1.0 of the target budget — 10ms to 16.7ms at 60fps — which is a large and entirely ordinary place for a real machine to live.
Implications: a transient can no longer permanently degrade quality; the worst case is a machine that oscillates between two adjacent rungs, and the backoff makes that rare and then rarer. Two tests pin it: a transient followed by 40s of dead-band work must return to step 0, and a rung-dependent workload where the step above genuinely does not hold must produce fewer than 30 rung changes over 320 simulated seconds — where a fixed 500ms probe would produce about twelve hundred.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: cardGlBox returns CSS pixels, and everything the stage hands
three.js is in that unit. The only device-pixel arithmetic left is the blit's
drawImage source rect, which reads the drawing buffer directly.
Why: three multiplies every setViewport/setScissor by its own
_pixelRatio on the way to GL. The stage was converting to device pixels first,
so the ratio went on twice. That is the identity at ratio 1 and invisible; at
0.5 it draws every card into the wrong quarter of the screen. It survived local
testing and four green checks in the same CI run for one reason: this machine's
ladder rests at step 0, so every screenshot was taken at ratio 1. The runner's
rests lower, and both modes screenshotted flat background — one distinct colour,
luma 14 to 14 — while contexts, programs, textures and draw calls all still read
correct. The cards were being drawn perfectly, somewhere else.
Implications: the acceptance now screenshots each mode a second time with the ladder PINNED to a reduced-resolution step. A bug invisible at full quality is not caught by a test that only ever runs at full quality, and waiting for a slow machine to reveal it is not a test — it is luck, and this time the luck belonged to CI rather than to a user.
Decided by: @cheyras
Decision: the workspace keeps @foilkit/core, @foilkit/patterns and the
rest. When these are published it will be under @cheyras/foilkit-*,
because there is no npm organisation behind this project and one person's scope
is a more honest home for it than an org created only to hold a name. Nothing in
the repository is renamed now.
Why: the scope a package is published under and the specifier the source
imports are different questions, and conflating them costs a churn commit across
every file for zero behaviour. Subtask 1 reserved foilkit on npm; the org was
never created, so @foilkit/* is not actually held.
Implications: README.md states the publish name so a reader is not
surprised. The rename happens once, at publish time, and the packages' name
fields are the only thing it touches — packages/* directory names,
pnpm-workspace.yaml and every import specifier stay as they are.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: the CORS question subtask 7 opened is answered: permissive.
Measured with curl -D -, with and without an Origin header, on
https://assets.tcgdex.net/en/base/base1/4/high.webp — 200,
access-control-allow-origin: *, access-control-expose-headers: Content-Length,Content-Range; the OPTIONS preflight answers 204 with
access-control-allow-methods: GET, OPTIONS. A cross-origin
<img crossOrigin="anonymous"> therefore uploads as a WebGL texture without
tainting the canvas. functions/image.ts ships regardless.
Why: only one of the three reasons for the proxy was ever about CORS. The other two hold whichever way the check landed: a volunteer-run CDN should not be hammered every time somebody scrubs a set, and — the one that matters for the corpus — subtask 4's frame registry resolves a framing from source URL + raster dimensions, so a URL under our control is what keeps that key stable when upstream re-encodes something. A green CORS result means dropping the proxy stays available later; it is not a reason to skip it now.
Implications: the frame registry's key is /api/image?p=… for anything the
editor draws over, and it stays that way even if upstream's URL structure moves.
The src= form exists so a recorded source URL passes through byte-identically.
SSRF is closed by construction rather than by an allow-list check: the function
only ever builds ${ASSETS_ORIGIN}/${path} from a path that matched a strict
five-segment regex.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: the static search index is 601–741 KB raw / 75–223 KB gzipped at catalog scale, partitioned by normalized first letter into 27 buckets loaded on demand.
Why (the measurement, n stated): /search was a Postgres full-text query;
static means an index in the browser, and subtask 7 flagged it as the one place
in the read path with real size implications. It was measured rather than
guessed: the real emitter was run over 20,500 synthetic cards / 41,471 printings
/ 184 sets — the catalog's own scale, taken from data/foil-pattern-cards.json
— with cardId lengths drawn from the real measured distribution in
data/foil-card-assignments.json (n = 341, mean 10.32 chars, min 6, max 15) and
card-name length swept at 11/14/18 chars under two entropy models to bracket
gzip. That range is the answer; the fixture bake measures 9.6 KB raw / 3.4 KB
gzipped at 300 cards.
Implications: 700 KB is small enough to ship whole, so partitioning is not
load-bearing — it is there so a first keystroke does not pay for the
twenty-five buckets it excluded, and because a bucket is the unit that grows
when the catalog does. data/search/index.json records the measured byte
totals, so the decision stays answerable against a number. RUN-BAKE.md names
~2 MB raw / ~600 KB gzipped as the threshold that would justify revisiting it.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: ownedOnly, the owned-only chip, the per-series owned counts, the
per-set "N owned" and the per-card ownership badge are removed from the
hosted editor — not hardcoded to false. They are replaced by
has-mask / no-mask / has-window-geometry, answered from
data/corpus-manifest.json rather than from a query parameter.
Why: there is no DeckPal account behind foilkit.deckpal.app, so an
owned-only filter has nothing to filter against. A dead parameter threaded
through four useQuery calls is worse than its absence, because the next person
has to work out whether it does anything. And the replacement is not a
substitute for the old filter — it answers the question a contributor actually
has, which is "what work is and is not done".
Implications: the baked catalog shards must never carry a user column, a
collection count or an internal id; tools/bake/guards.ts asserts that
structurally by walking every emitted object for a forbidden key. The ownership
dot on a card thumbnail became a MASK dot — the contribution fact in the same
three pixels.
Decided by: Claude Fable 5 on behalf of @cheyras, on 3a's measurement
Decision: / renders the verification map's rule groups ranked by leverage
(printings ÷ (exemplars + 1)). The card picker lives on /card and is what
you reach for when you already know which printing you came for.
Why: 3a produced the ranking specifically to answer "is the editor a card
picker or a queue", and the answer is in the numbers: the top group is
modern-swsh / sheet / energy-symbols-ii / set governing 2,041 printings with
zero human exemplars, and the top five all govern 500+ printings each. A
picker asks which card you came for; most of the time the honest answer is
"whichever one teaches the rule the most".
Implications: the queue draws no progress bar and no completion state, and
the end-to-end run asserts there is no <progress> element anywhere. Nothing in
this corpus is ever blank — a card with no human attention is guessed, not
missing — so a completion bar would be a lie about both halves. Groups above a
leverage floor are labelled as a regeneration pass rather than offered as a
button, because that is a tool run against the corpus, not a thing a browser
does.
2026-09-01 — A staged session's seed is immutable, which is what makes one correction per session free
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: updateMaskSession has no path to session.seed. Every
intermediate save updates pixels, geometry, uniforms and the comment; the seed
is written once, when the session is created, and the single PUT at submit
carries derivation { startedFrom, parent } from it.
Why: the old lab rewrote session on every Save so the mask just written
became the parent of the next — ten saves on a card produced ten correction
records, ten parent PNGs, ten diffs. Collapsing that to one needed no change
to the provenance contract at all: writeMaskRecord reads the parent from
disk at write time and derives derivation_method by diffing saved pixels
against what the declared seed rasterizes to. Making the seed structurally
unreachable turns "one correction per session" from a discipline into a
property.
Implications: keep-mine on a conflict changes nothing about the payload —
the correction lands against current upstream automatically, which is exactly
what "reparented onto current upstream" means. The trade is ten weak training
samples for one strong one per card, which is right for the corpus and is worth
remembering when #10 recalibrates exemplar weights.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: conflict detection runs two comparisons: the sha256 of the
mask that answers now against the one stashed at seed time, AND the identity of
the record that answered. Five outcomes — none, parent-changed,
alias-moved, parent-vanished, parent-appeared — and three choices,
keep-mine / take-theirs / re-trace. Never a merge.
Why: masks alias across variants by prior.scope, so upstream can grow a
sibling mask that changes which record answers for the staged variant
without touching the file that was seeded from. A sha comparison against the
seeded file sees nothing. The pixels are not in conflict in that case; the
provenance parent is, and a correction recorded against the wrong parent is a
lie in the training signal.
Implications: the probe carries an identity as well as a hash, and the end-to-end run asserts the alias case separately from the sha case — with identical pixels on both sides, so a test that only moved the sha would pass while the bug shipped. Nothing auto-merges: two people painting the same alpha channel have no lines to reason about, and any automatic result is plausible-looking garbage nobody drew.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: a staged session persists current pixels, the seed, and the parent sha. Not the undo history.
Why (measured): mask PNGs average 7.7 KB and the whole 96-file corpus is
738 KB — masks were never the constraint. MaskEditor keeps 12 ImageData
snapshots at canonical raster, ~1.4 MB each, ~16 MB per card. Persisting three
orders of magnitude more data to preserve a 12-step history through a tab close
is a bad trade.
Implications: IndexedDB is still the right store (binary-friendly, async, quota in hundreds of megabytes), but it was chosen against the undo number rather than the mask number. The end-to-end run asserts a stored session is under 400 KB, so a future change that starts persisting history fails loudly.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: functions/mask.ts materialises the corpus into /tmp from the
repository head, runs the real writeMaskRecord against it, and commits exactly
what changed through the git data API with force: false. The writer capability
is a list in functions/_lib/writers.ts, mirrored in the editor, with a test that
reads the editor's source and compares.
Why: running the same writeMaskRecord is what keeps derivation_method
server-derived — a second implementation of that rule would be a second place
for it to be wrong. Compiling the list in rather than reading it from an env var
means granting the second writer is a reviewable commit rather than a dashboard
click, and the mirror test means the UI can never offer a save the server
refuses, or hide one it would have allowed.
Implications: @foilkit/forge's FRAMES_FILE had to become lazy and
FOILKIT_FRAMES_FILE-overridable, because it walked for a pnpm-workspace.yaml
at import time and threw inside a bundle that has none. The gate is not
weakened: a raster matching no frame record still refuses to save.
Attribution limitation, stated rather than hidden: the commit's committer is
the project token's account and the author is the signed-in writer
(<id>+<login>@users.noreply.github.com). Subtask 9's GitHub App collapses the
two; this is the honest interim. The force: false on the ref update is the
same principle as the conflict UI one layer up — if somebody pushed in between,
refuse rather than discard.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: deleteMask / deleteWindow / deleteCanon stay live for a
writer-capability holder and are absent from the staging layer. There is no
"stage a deletion" path and no flag to flip.
Why: a contributor's first available action should not be removing ground truth. A deletion also has no diff to review — the PR would be an empty file and a claim — so there is nothing for #9's pipeline to put in front of a reviewer.
Implications: re-open it when there is a reviewer flow that can weigh one. Deleting a canon file is recorded in its commit message as "this pattern is recorded as never canon'd again", because that absence is real signal for #11's queue rather than a reset to defaults.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: the staging layer holds two session kinds —
mask:<cardId>:<variantId> and canon:<patternId> — sharing a store, an export
bundle and the conflict machinery, with their own eventual PRs.
Why: a canon file is per-pattern and global. It does not belong to any card, so it cannot ride the one-session-per-card rule without lying about what it is.
Implications: a canon conflict is sha-only — a full uniform snapshot has no
aliasing — and offers keep-mine / take-theirs and no re-trace, because
there is nothing to ghost underneath a slider. The hash is taken over a
canonicalised (key-sorted) serialisation, so two files differing only in key
order are the same canon.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: the comment box no longer writes issues/foil/<id>/report.md +
context.json into the repository. The text is stored in the staged session and
carried in the export; it becomes the PR body when #9's pipeline ships.
Why: a stranger's note about their own change belongs in the review, not in the tree. Committing it also means every drive-by note is a permanent file somebody has to prune later.
Implications: stored and exported, never committed — so nothing is lost in the interim, and the eventual PR body can be assembled without re-deriving anything. A direct write puts the note in the commit message body instead, which is the same idea in the place a writer already has.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: the editor computes an offline agreement number by rasterizing the seed client-side. It is shown as provisional, never persisted into a session, and never sent in a payload.
Why: the server decides derivation_method and agreement by diffing the
saved pixels against what the declared seed rasterizes to, and the client must
never label a mask. But the client owns the same rasterizer, and editing
entirely blind for a whole session hides the one number that says whether the
work is improving the shared rule or fighting it.
Implications: provisionalDiff.ts is a line-for-line port of forge's
rasterizePriorAlpha and diffMask, because forge reaches node:zlib through
its PNG codec and cannot enter a browser bundle. A parity test runs in Node —
where both are importable — and asserts byte-equality across seven geometries
including the inverted sheet, a zero-area rect and a radius larger than its box.
If forge's rasterizer moves and the port does not, that test fails. That is the
only thing that keeps a port a port.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: functions/mask.ts downloads data/frames.json from the repository
head into its /tmp workspace and points FOILKIT_FRAMES_FILE at it, per
request.
Why: the frame gate is what stops a mask being authored over a scan nobody
can name, and a stencil cut for an unnamed picture is worse than no stencil.
Bundling the registry into the function would freeze it at deploy time, so a
data/frames.json update would authorise masks against numbers the corpus no
longer uses — and the drift would be silent, which is the failure mode the gate
exists to prevent.
Implications: one extra API read per write, on a path that already makes several. The registry and the pixels it authorises are the same generation, by construction.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: data/corpus-manifest.json derives the uncanon'd list rather than
trusting a count, and the derived answer is 12: ace-spec, acid-wash,
crosshatch, disco, energy-symbols, energy-symbols-ii, ex-starfoil,
pokeball-masterball, prism, prismatic-pokeball, rainbow-mirror,
tcg-classic.
Why: subtask 5 recorded 13, which is 45 implemented patterns − 32 canon files. That arithmetic counts none, the no-foil recipe, which has no canon by
definition and never will. The corpus itself has not moved.
Implications: the builder prints a FINDING: line whenever the derived list
disagrees with the recorded count, and does not fail the build over it — a
count is a claim and the corpus is the measurement. docs/HOSTED-EDITOR.md was
corrected.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: tools/build-functions.mts esbuild-bundles each route into one
self-contained .func and writes the whole deployment — static, functions and
routing table — into .vercel/output. Vercel uses that directory verbatim.
vercel.json is reduced to an install command and a build command; its
rewrites, headers, functions and outputDirectory keys are deleted
because the Build Output API ignores them and a misleading config is worse than
none. The function sources move from api/ to functions/.
Why: the first production deploy answered 500 FUNCTION_INVOCATION_FAILED
on every function — a boot crash, before any handler ran — while every test in
this repository passed. Two independent packaging causes, both reproduced
locally by booting the built artifact:
-
@vercel/nodetranspiles each.tsfile in place and does not rewrite import specifiers.functions/image.tssaysfrom './_lib/http.ts'; the emittedimage.jsstill says it; the file beside it is_lib/http.js.ERR_MODULE_NOT_FOUND, every function, always. The extension cannot simply change: Node's native TypeScript support is strip-only, so a relative import from a.tsfile must end in.ts. Measured on Node 24, all three forms:./dep.tsresolves,./dep.jsdoes not,./depdoes not. The source cannot use.jsand the Vercel builder cannot use.ts. - Workspace packages did not resolve either. nft traced
@foilkit/forgeand copiedpackages/forge/distinto the bundle, but emitted nonode_modulesand no symlink, so nothing mapped the bare specifier to the files sitting right there.mask,canonandwindowwould each have crashed on first use; the report named onlyimagebecauseimagewas exercised first.
A bundle has no unresolved specifiers left to get wrong, which removes the category rather than the two instances.
Implications: esbuild becomes an explicit root devDependency — a build
tool, named as one; packages/** still has none. The api/ → functions/
move is not cosmetic: with the sources in api/, Vercel's zero-config detection
ran its builder as well as ours, both wrote into the same .func directory,
and which handler the .vc-config.json named was a race. The routes are still
served at /api/*. The runtime is pinned to nodejs22.x on both sides because
engines: { node: ">=22" } makes Vercel override the project's 24.x setting.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: every write and auth handler calls refuseIfUnconfigured() at the
top, before a cookie is parsed or a repository is read, and answers 503 with a
missing: [...] array naming the variables. /api/image requires no
environment at all. /api/auth/signout requires none either, so a
half-configured deployment can still clear a stale cookie.
Why: a boot crash, a 500 and a missing variable look identical from a
browser and mean completely different things to whoever has to fix it — the code
was packaged wrong, the code hit something unexpected, or the code is fine and
setup is unfinished. The first deploy conflated all three, and the one useful
signal ("you have not set FOILKIT_GITHUB_TOKEN yet") was indistinguishable
from a packaging failure. Checking configuration first also stops a signed-out
visitor being told to sign in before hitting the same refusal anyway.
Implications: tools/verify-functions.mts asserts the whole ladder — empty
environment, secret-only, and the real state production is in — against the
BUILT artifact rather than the source, and CI runs it on every push. Card scans
work on a deployment with nothing configured, which is deliberate: they are what
a visitor sees before they have any reason to sign in.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: FoilStage makes the face texture's orientation explicit on both
decode paths — createImageBitmap(..., { imageOrientation: 'flipY' }) and a
mirrored drawImage in the canvas fallback — and sets flipY = false so three
cannot flip it again.
Why: the plane's uv.y = 0 is its bottom edge and the vertex shader passes
uv through untouched, so the texture's v = 0 must be the image's bottom row.
Texture.flipY defaults to true and would arrange that for an image or a
canvas, but not for an ImageBitmap, which uploads as encoded. Every real
card scan rendered vertically mirrored. Measured rather than argued: the render
was correlated against the source scan in four orientations, and as-is scored
-0.37 while flipped-Y scored +0.24; after the fix the two swap to
+0.25 and -0.37.
Why nothing caught it: the render-parity harness and the stage acceptance
both run on blank bases (uScanBase 0), where the face is a flat colour and
a vertical flip is invisible by construction. The editor's own end-to-end run
stubbed the scan with a 1×1 PNG, which is the same blind spot in a different
costume. It took a real scan on a real deploy to see it.
Implications: the fixture scan in apps/editor/e2e/run.mjs is now 64×88,
white on top and black on the bottom, and an assertion samples the rendered
canvas and requires the top band to be brighter. The guard was checked against
the bug it was written for — reverting the fix fails it. A flat-base test can
never again stand in for "the card renders correctly".
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: DEPLOYMENT.md's environment table now says 503 not_configured
for FOILKIT_SESSION_SECRET, the OAuth pair and FOILKIT_GITHUB_TOKEN, and
notes that 502 write_failed is a different answer entirely — the token was
present and GitHub refused the commit.
Why: config.ts sends 503, tools/verify-functions.mts asserts 503, CI
runs it, the same document's own failure-modes section says 503, and the live
site answers 503. The table was the only thing still saying 500 and 502 — and it
is the part somebody reads while deciding whether they are looking at a
packaging failure or an unfinished setup, which is the exact distinction the 503
exists to draw.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: the client/server writer-list parity check moves out of
session.test.ts into writers.test.ts, the file writers.ts and
capability.ts both name. It also now asserts that the two isWriter
IMPLEMENTATIONS agree, not only that the arrays match, and that the list is
non-empty.
Why: the citations pointed at a file that did not exist. That is harmless
until somebody grants the second writer, edits one list, greps for the named
test to run it, and finds nothing. The matching RULE is duplicated as well as
the list, so a list that agrees while the rule does not would let the UI and the
server disagree about CheyRas while the test said they were in step.
Implications: the editor's copy is still read as SOURCE TEXT rather than imported — it is front-end code whose import graph is free to grow a React or Vite dependency, and a parity test that fails because the editor imported something is a test people delete. Checked against the bug it exists for: adding an entry to one list only fails it.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: tools/bake/emit.ts writes version + generatedAt + source +
resolverVersion onto the per-series and per-set shards, not only onto
catalog/index.json and search/index.json. Card detail is read out of the set
shard, so that covers it too.
Why: the spec says every artifact is stamped. A shard that cannot be dated cannot be told apart from a shard out of a different bake, and "they shipped together, check index.json" is an answer that is wrong exactly when a partial deploy or a hand-copied file is what you are chasing. About 70 bytes against set pages that run 15 KB.
Implications: the change is to the TOOL. The shards committed under
data/catalog/ predate it and are NOT rewritten — adding a stamp to an existing
file would be inventing a date. They restamp on the next bake, which is the only
thing that can honestly stamp them. emit.test.ts asserts the stamp on every
catalog artifact and on both index files; the editor's shard types carry the
fields as optional, because unstamped shards exist in the wild today.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: when a staged session for (cardId, variantId) holds a PNG, that
PNG owns the mask canvas. The saved-hand-mask loader in FoilLab.tsx does not
fetch and does not draw while such a session exists — and it does not run at all
until the session store has been read (staging.loading). Upstream is loaded
only when no staged pixels exist, or deliberately, through the conflict flow's
take-theirs / re-trace.
Why: the loader was unguarded. On every reload it fetched the committed mask
and drew it over a canvas the staged-restore effect had already painted — a
network fetch beats a data-URL decode, and the restore had latched
restoredFor and would not run again. The session RECORD survived; the PIXELS
did not. Worse, staging.save re-reads the store, and the store's list was a
dependency of the loader, so pressing Save to session re-ran the fetch and
then wrote the upstream pixels over the contribution. Measured live: provisional
diff 61.1% (+10,387/-39,869) before a reload, 66.4% (+0/-39,869) after — exactly
the upstream mask's own manifest agreement. A contributor's staged PNG is the
only copy of that work in existence; nothing may overwrite it implicitly.
Implications: staged is now DERIVED from the store rather than mirrored in
useState — the mirror lagged the selection by one commit, so every guard
written against it read the previous card's session for one render. The
restoredFor latch resets on card change, or leaving a staged card and coming
back showed the pixels of whatever was opened in between. The editor's E2E
asserts the provisional diff NUMBERS are unchanged across staging and across a
reload, and that a second Save to session leaves the stored PNG byte-identical.
The existence of a session is not asserted as a proxy for any of that, because
it was true throughout the bug.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: FoilLab holds the card id a deep link asked for until the browse
chain has been re-derived for it, and the three auto-select effects
(series -> set -> card) do not run while it is held. It is released when the
loaded detail agrees with the selection, or immediately if the catalog has no
entry for the id — in which case the surface says so instead of silently
substituting a different card.
Why: a deep link arrives with seriesSlug and setId empty, because only
the card detail knows what they are. The auto-selects fill empty slots, and the
SET step fills setId by CLEARING cardId. It needs no network round trip and
the detail query needs one, so it won the race, landed on the hardcoded
base1 / base1-8 default, and the URL-sync effect then rewrote the address
bar to match. The contributor was working on a card nobody sent them. Measured
live: 3/3 wrong for queue "Work this" picks outside base1, 2/4 wrong for cold
deep links.
Also fixed here: Catalog.card() derived the set shard as "everything
before the last hyphen" and gave up if that 404'd. The bake deliberately
TOLERATES ids that break that round trip — it counts and prints them rather than
failing a whole catalog over one malformed promo id — so the reader has to be as
forgiving as the writer. Otherwise such a card is unopenable, and the
auto-select fallback is exactly what pressing "Work this" on one of them looks
like. It now walks hyphens right-to-left, bounded at four candidates.
Implications: the editor's E2E asserts the address bar and the card on screen for several queue picks and for a cold deep link into another series. And, because a localhost static server answers every file in under a millisecond and therefore cannot lose this race, the harness can now delay chosen artifacts: everything the DEFAULT chain needs stays instant while the target's shards go cold. That is the production shape of the race and the only shape in which the bug is visible at all — without it the new assertions passed against the unfixed code, which is the worst outcome a regression test has.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: the direct-write paths surface their outcome. saveMask,
deleteMask and saveWindow no longer swallow the throw: the error line shows
the message api.ts's writeError already parsed out of the server's response,
and says that nothing was discarded. A failed save re-asserts the dirty flag and
touches neither the canvas nor the session; a failed DELETE now clears nothing
at all, where it used to clear the whole surface and report a success the
repository never granted.
Why: catch {} with the comment "surfaced by the dirty flag remaining" was
not a surface. A writer pressing Save against a 503 saw a button that behaved
exactly as it does on success, and the server's actual sentence — which names
the missing variable — was parsed and then thrown away.
Implications: three stale strings went with it ("is the branch api up?", "is the 3712 api up?", and "Saved to issues/foil/" for a note that now goes to a staged session). The failure text is the server's rather than ours, so a new refusal reason needs no client change to become readable.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: the "Save card overrides" button is removed, and putOverride /
deleteOverride are deleted from api.ts rather than left as stubs. Adjusted
uniforms ride the staged session — stageMask already writes them — which is
what spec 8 says per-card overrides are. The panel states this, and states
plainly that a writer's Save commits mask pixels rather than sliders.
Why: putOverride threw unconditionally. There is no /api/override route,
because data/foil-overrides/ has never held a record, and a PUT with no reader
would be a promise rather than a feature — but it was still wired to a live
button. The one thing it reliably produced was a red "Override save failed" that
blamed a server for refusing a request it had never been sent. A button that
cannot succeed is worse than no button, and a client method that can only throw
is a trap for the next caller.
Alternative considered and declined: making the button call stageMask().
It would work, but it would create a mask session — carrying the canvas PNG —
for somebody who only meant to save sliders, and a writer has no session UI on
this surface. Saying what actually happens beats inventing a path to make a
button true.
Decided by: Claude Fable 5 on behalf of @cheyras
Decision: the card pane HEADs /api/image for the printing on screen and,
on a 404, says "scan unavailable upstream — this printing has no image at the
catalog's path"; on a 502 it says upstream answered with something that was not
a scan. Only OUR proxy is probed — a cross-origin URL's status is not readable
and guessing at one would put a wrong explanation under a card that renders
fine — and a network failure says nothing at all.
Why: /api/image already distinguishes those two cases deliberately. Its
own header gives the reason: a contributor staring at a blank editor needs to
tell "no scan exists" from "the CDN is serving an error page with a 200 on it".
The editor was throwing that distinction away and rendering a void, and a void
reads as a broken editor rather than as a gap in a volunteer CDN.
Implications: one HEAD per card opened. HEAD is the method /api/image
documents for exactly this question — every header and no body.
Decided by: Chey Rasmussen (plan), Claude Fable 5 (implementation)
Decision: /api/contribute is the contribution pipeline. Any signed-in
GitHub account may POST a staged session to it; the function validates it
server-side, mints a one-hour installation token from a GitHub App scoped to
cheyras/foilkit with contents:write + pull_requests:write, commits the
session to contrib/<login>/…, and opens or updates one pull request.
The App is a second, independent credential beside the direct-write PAT
rather than a replacement for it. Both paths ship. functions/_lib/config.ts
gives each its own 503 ladder naming its own variables, because a deployment
can legitimately have either, both or neither and each combination has a
different honest answer.
Why: a PAT is a long-lived bearer secret belonging to a person, valid until somebody notices it leaked; an installation token is minted per request family and expires in an hour. More importantly the App is its own ACTOR, so a contribution pull request is opened by the App rather than by the maintainer's account — which is what makes "cheyras reviews a contribution" a real review instead of the maintainer approving himself.
The direct write stays because routing Chey's own work through submit-and-review would put a queue between him and his own repository for no gain, which is the same reason the writer capability exists at all.
Implications: three new environment variables, declared in DEPLOYMENT.md
in this commit (contract B11's shape). The maintainer creates the App by hand —
DEPLOYMENT.md names the exact permission set, and it is exactly two. The
commit is authored by the App bot with a Co-authored-by: trailer for the
contributor, which is the mechanism that puts their avatar on the commit and
their name on the pull request. People contribute where their name shows up.
Decided by: Claude Fable 5, on behalf of @cheyras
Decision: a deliberate deviation from the plan, which said "private key in a
secrets manager, not env". FOILKIT_APP_PRIVATE_KEY is a Vercel sensitive
environment variable.
Why: Vercel's encrypted environment variables are this deployment's
secrets manager. The value is write-only once set, vercel env ls prints names
and targets and never values, and it is injected into the function process at
run time — which is the same property a secrets manager provides. A separate
service would add an outbound dependency on the request path, a second
credential to bootstrap it with, and a new failure mode, in exchange for nothing
this deployment does not already have.
The property the plan was actually protecting — "the key is never in a file, a
log or a transcript" — is protected here and is TESTED:
functions/_lib/app-auth.test.ts asserts that a malformed key produces a 503
naming the variable and that the message never contains the value, and that
a failed token exchange never echoes the JWT back even when GitHub's error body
contains it.
Implications: rotation is vercel env rm + vercel env add + a redeploy,
which is a maintainer action and needs a deploy. If foilkit ever grows a second
runtime that needs the same key, revisit — one key in two env stores is the
point at which a real secrets manager starts paying for itself.
Decided by: Claude Fable 5, on behalf of @cheyras
Decision: the submit endpoint validates the assembled shader
structurally — the pattern ABI (vec3 foilPattern(vec2, vec2)), brace and
paren balance across PREAMBLE + pattern.glsl + MAIN, the absence of a
#version directive, and that every uniform the canon file sets is actually
uniform-declared by the assembled source. The real compile happens in the
pr-evidence GitHub Actions workflow, which renders the submitted state through
headless Chromium on SwiftShader and fails the job on any page or console error.
Why: the plan asked for a WASM GLSL validator in the function "if a workable zero-native-dep option exists". It was evaluated and there is not one, and the reason is DIALECT rather than size:
- foilkit's composite is GLSL ES 1.00 —
varying,attribute,texture2D,gl_FragColor, no#version. That is WebGL 1, which is what@foilkit/threetargets. -
@webgpu/glslang(5.1 MB unpacked, last published 2021) compiles Vulkan GLSL and requires#version 450with layout qualifiers. It would reject every shader in this repository. A validator that fails on valid input is worse than no validator, because its failures are indistinguishable from real ones. -
naga-wasmis unpublished on npm; naga's GLSL frontend supports ES 3.00 and desktop profiles, not ES 1.00. - A pure-JS parser (
@shaderfrog/glsl-parser) parses but does not type-check or link, so it would not catch the failures that matter — and it would be the first runtime npm dependency in a workspace whose packages have none.
SwiftShader is not a fallback from any of those. It is a real GL driver compiling the real dialect, which is a strictly stronger gate than the best of the rejected options would have been.
Implications: the compile is a pull request check rather than a submit-time
refusal, so a shader that does not link opens a pull request and then fails on
it. That is the cost and it is acceptable: the structural checks catch the
failure modes a contributor can actually cause (a canon file naming a uniform
its recipe does not declare is the realistic one), and a recipe whose GLSL does
not compile is a code change, which goes through review anyway.
functions/_lib/validate.test.ts runs the structural check across the WHOLE
recipe corpus, so a recipe added with unbalanced braces fails the unit suite
rather than a browser.
Decided by: Claude Fable 5, on behalf of @cheyras
Decision: the 8-frame tilt sweep is pushed to an orphan branch named
pr-evidence, under pr-<number>/<name>.png, and referenced from a single
updated-in-place pull request comment by its raw.githubusercontent.com URL
with ?v=<head sha> to bust GitHub's image proxy cache. It is uploaded as a
workflow artifact as well, always.
Why: the requirement is that the strip renders inline in the pull
request. Two approaches do that, and the other one — committing the strip into
the pull request's own branch under evidence/ — was rejected for two reasons
that compound:
- It merges into
mainunless somebody deletes it, and a merge-cleanup step nobody is blocked on is a step that gets forgotten. The corpus would slowly accumulate generated PNGs beside the measurements. - It puts a generated binary the contributor did not author into the diff that is the review. A reviewer reading "three files changed" should be reading three files the human changed.
The orphan branch shares no history with main, never merges anywhere, holds
one directory per pull request, and can be deleted wholesale without touching a
single contribution.
Implications: it depends on cheyras/foilkit being public — GitHub's
image proxy can only fetch a raw URL from a public repository. If foilkit ever
goes private the images stop rendering inline; the strip is still on the branch
and still an artifact, and the comment should then name the artifact instead.
Written down in DEPLOYMENT.md's failure table so the next person does not have
to rediscover it.
Decided by: Claude Fable 5, on behalf of @cheyras
Decision: tools/parity/tilt-strip.mjs renders the submitted mask over the
blank card base — the parity host's four flat tones — never over a card scan.
The host page's new maskUrl parameter binds the mask as uMaskTex; there is
deliberately no parameter for a face image.
Why: AGENTS.md F2, the standing ownership rule: ship nothing we do not own
outright, not in data/, not in a demo, not in a test fixture. The evidence
strip is COMMITTED — to the pr-evidence branch — so a strip containing a
rendered card scan would put third-party pixels into this repository, which is
exactly the thing that makes a CC0 dedication unreliable. That the pixels are
composited rather than copied changes nothing about who owns them.
The blank base is also the better picture on its own merits. It shows how the foil behaves inside the region the human drew, at eight angles, with no printed ink competing for attention — which is the thing under review.
Implications: a reviewer cannot see the mask registered against the artwork
from the strip alone. That is what the .diff.png committed beside the mask is
for (green added, red removed against the era rule), and what opening the card
in the editor is for. If a scan-registered view is ever wanted, it belongs in
the editor where the scan is fetched live and never stored — not in a committed
artifact.
Decided by: Claude Fable 5, on behalf of @cheyras
Decision: validateMask requires that a submitted mask decodes, is exactly
504 × 704, matches its declared dimensions, has some coverage, and has less
than 98% coverage. It does not check the RGB channels.
Why: the content of a mask lives entirely in its alpha channel — MAIN
reads texture2D(uMaskTex, …).a and nothing else, so RGB is display tint for
humans looking at the PNG. Measured against the committed corpus: most masks
carry a single tint triple, and base1-5/19.png carries 1419 distinct
triples from a canvas composite. Those masks render identically. A check that
rejected varied RGB would be enforcing a convention rather than a fact, and it
would reject real data already in the corpus.
The two bounds that ARE enforced are the ones with meaning. A mask covering
nothing has no measurement in it. A mask covering everything is what the
renderer already does with uMaskTexOn = 0, so committing one adds a file that
changes nothing while claiming a human looked. The committed corpus runs 0.157
to 0.537 coverage, so the 0.98 ceiling is far outside anything a hand mask
produces.
Implications: the ceiling is a named constant with a test asserting the MARGIN rather than the number, so a future full-face convention is a decision somebody makes rather than a limit somebody trips over.
Decided by: Claude Fable 5, on behalf of @cheyras
Decision: functions/_lib/canon-entry.ts composes and serialises a canon
file, and both functions/canon.ts (direct PUT) and functions/contribute.ts
(pull request) call it. Identical uniforms produce identical bytes on both
paths.
Why: two write paths reaching one file format is two dialects of that format, eventually. Extracting the composer also fixed two fields the direct write had been dropping on every rewrite, both silently:
-
tunedUnderContract.tools/parity/data-receipt.mjsFAILS on a canon file without it, and all 32 committed files carry it. A save that dropped it succeeded, and CI broke on the next push with no obvious connection to the save that caused it. -
frozen. That is a human decision — "these numbers are settled, stop re-tuning them". AGENTS.md F4: a machine write may never overwrite one. Dropping it on a rewrite is exactly that rollback, performed by a save that looked like it only touched uniforms.
Implications: tunedUnderContract is now stamped with the CURRENT law for a
live tuning session (a human just chose these numbers under this main()) and
preserved for a mechanical rewrite. contract — the law the file is READ under
— is still carried through untouched, which is what it was always for.
Decided by: Claude Fable 5, on behalf of @cheyras
Decision: functions/_lib/pr.ts encodes a branch name for a URL path with
refPath() — per segment, slashes intact — rather than with
encodeURIComponent.
Why: encodeURIComponent('contrib/octocat/x') is contrib%2Foctocat%2Fx,
and GitHub's git-ref routes 404 on that: the ref is a path, not a parameter,
and heads/contrib/octocat/x has to arrive with its separators. github.ts has
the same construction and is not wrong, because the direct-write path only ever
names main — which is precisely why nobody would have noticed by reading it.
Recorded because of HOW it was found: the mocked-GitHub test in
functions/_lib/pr.test.ts routed on the exact path and failed on the first
run. A test that asserted "the promise resolved" would have passed against a
mock that answered anything.
Implications: any future call that puts a ref in a path uses refPath. The
pull request lookup still uses encodeURIComponent, correctly — head=owner:branch
is a query parameter and the slashes there are data.
2026-09-05 — The tilt strip reads the canvas; page.screenshot is not portable enough to gate a merge
Decided by: Claude Fable 5, on behalf of @cheyras
Decision: tools/parity/tilt-strip.mjs captures each frame by calling
toDataURL() on the renderer's own canvas — armed by window.__grabNext() and
serviced by the render loop in the same task as the draw, while the drawing
buffer is still valid — and crops the card rect in Node from __cardRect.
tools/parity/run.mjs keeps using page.screenshot({ clip }) and is unchanged.
Why: page.screenshot asks the BROWSER COMPOSITOR for a frame. On a GitHub
Linux runner that call logged fonts loaded and then sat until it timed out;
on a Windows Chromium build it returned fine. Three CI runs went into the
diagnosis, and neither of the first two hypotheses was right:
-
animations: 'disabled'makes Playwright wait for the page to settle, and settling includes an animation frame this harness has stubbed. Removing it changed nothing. - A page whose rAF loop has stopped calling itself never asks for a compositor
frame. Restoring the real
requestAnimationFrameat the fixpoint — which was proved not to move a pixel, sha256b905f883968bb761…before and after — changed nothing either.
The compositor is not part of the measurement. It contributes a font pass, a device-scale negotiation, a viewport and a surface capture, none of which the card render depends on, and all of which vary by platform. Taking it out of the path deletes the class rather than the instance.
Implications:
- The strip is no longer byte-comparable with anything produced by the
screenshot path. That is fine and is stated in the file header: determinism is
a run-to-run property, and it holds — a control pair of
base1-4/15gives8cc84702a88a5d94…twice. -
preserveDrawingBufferstays off. Turning it on would change the renderer's configuration for every consumer of this page, the moving receipt included. Arming a flag the loop services is what makes it unnecessary. - The card rect is now arithmetic (the page's own inverse of its
fit()projection) rather than aboundingBox(), so the crop cannot depend on layout, scrollbars or a window manager. -
run.mjshas never been run headlessly on Linux.apps/demo/acceptance.mjsscreenshots happily in CI and does not stub rAF, which is why nobody had met this. If the moving receipt is ever run on a runner and hangs, this entry is the answer — and switching it to canvas capture is a separate decision that needs its own control pair, because its recorded sha256s are compared against other builds of the same modules. - Found by opening a throwaway pull request and watching the workflow. Nothing short of that would have found it: the offline structural check, actionlint, shellcheck and four green local renders all passed the whole time.
Decided by: Claude Fable 5, on behalf of @cheyras
Decision: EXEMPLAR_WEIGHT becomes the owner-verified row of a table keyed
on (derivation_method × provenance tier), EXEMPLAR_WEIGHT_BY_TIER. Three
tiers: owner-verified (a writer-capability holder authored or verified the
pixels — the historical weights, unchanged), contributor (a merged
contribution nobody with the capability has verified), and unattributed
(machine output, or a v5+ record with no author). The last two are zero across
every method.
Sidecar v5 adds two recorded fields and one derived one:
-
author— RECORDED, server-side, from an identity the route already verified. The direct write takes it from the cookierequireWriterjust checked; the contribution path takes it from the session, which the contributor cannot contradict because the App composes the commit. -
verification— written only by a writer-gated route (PATCH /api/mask, orcorpus.ts verify --aslocally), and re-checked on every read:deriveTierhonours it only whenverifiedByholds the writer capability. -
provenanceTier— DERIVED on everynormalizeSidecar, exactly likeauthorship/reviewStatus/frame. A hand-edited value is overwritten.
The writer list moved to @foilkit/core so forge can run that check in a CLI
with no HTTP request in sight. functions/_lib/writers.ts re-exports it and is
still the boundary; the editor's copy is still a separate literal reconciled by
writers.test.ts.
Why: The weights were calibrated when the only hand in the corpus was the
owner's, so hand meant both "a human painted this" and "this is how the card
truly is". Step 9 shipped a pipeline where strangers open pull requests. Those
became two claims, and only the first is visible in the pixels — which is the
one thing this module has always insisted on. An unreviewed stranger's mask at
weight 1 would not be a mislabelled file; it would be an era's rule derived from
work nobody looked at.
Zero rather than "low", which the spec left open. Three reasons, in order of how much they cost:
-
learnPolicytakes a weighted mean over a pool that is single-digit today and crosses a hard threshold atvoteThreshold0.5. With Σweight ≈ 2–3, even w = 0.1 moves a class share several points — enough to flipcarriesFoilat the margin. "Low" is not "harmless"; it is "changes the answer quietly". - Any weight above zero admits the mask to the pool at all, because
isExemplarEligibleis a> 0gate. Admitted means it is cited as an exemplar on generated masks, counts towardexemplarsInGroup, and lowersleverage = printings ÷ (exemplars + 1)— the number that decides where the next hour goes. Unreviewed work must not make a rule group look served. - Zero is reversible in one owner action and the full weight comes back. A nonzero default is not: by the time anyone notices, it has moved every derived number downstream.
3a's finding that most current evidence is ai-corrected at 0.6 argues the same
way. The pool is already thin and already discounted; thin is a reason to be
more careful about what joins it.
Implications:
-
The committed corpus is unchanged in weight.
corpus.ts migrateran over all 20 sidecars: 15owner-verified(the human work), 5unattributed(thewindow-artgateproposals, which are weight 0 by method anyway). The exemplar pool is 15 before and 15 after. v1–v4 compatibility is permanent. -
The historical inference is MATERIALISED at the version bump, not left to
expire.
deriveTier's pre-v5 branch reads absence-of-author as the owner (RELICENSE.md records the sole-author fact), and that branch is keyed on the version — so a schema upgrade that only renumbered the file would have rewritten the whole corpus asunattributed, weight 0, and emptied the pool every rule here is derived from.upgradeSidecarRecordstampsHISTORICAL_AUTHOR; a test asserts that login still holds the capability, so the constant and the list cannot drift apart in silence. -
A pre-v5
airecord is attributed to its GENERATOR, not to the owner. Weight 0 either way; what changes is that a mask never displays a green "owner-verified" beside its own amber "AI · UNREVIEWED". -
A BUG WAS FOUND AND FIXED BY RUNNING THE MIGRATION FOR REAL.
corpus.ts migratesynthesized a fresh single-entrylineageunconditionally. That was correct for exactly as long as the command only met pre-v3 records (they have none); the v4→v5 pass was the first to meet records that DO have one, and the first run deleted the entire 4b frame-migration history from all 20 sidecars. Caught by reading the diff, not by any test. The upgrade is now a pure function (upgradeSidecarRecord) with a test that asserts the lineage survives — a migration that lives only inside a CLI is a migration nothing can test. -
The forged-field defence has three layers, and the third is honest about
its limit. (1)
validate.tsrefuses any submission carryingauthor,verification,provenanceTieror a derived label — a deep scan, because nobody who wanted to lie would put it at the root — with a named 422 before a branch exists. (2)deriveTierre-checksverifiedByagainst the writer list on every read, so a block hand-committed in a fork PR grants nothing. (3) A hand-crafted PR could still name a real writer over pixels no writer saw. Nothing in the file can disprove that; what closes it is that such a PR did not come from the App, so a human reads it, and the lie is in the diff. -
exemplarWeightOffails CLOSED and deliberately does not reuseexemplarWeightFor'sowner-verifieddefault. That default is right for a method named in the abstract ("what is ahandmask worth at best") and actively dangerous for a record: a fixture or a stale value with no tier would otherwise be silently treated as ground truth. Two functions, two questions. -
Promotion rides the existing write path.
PATCH /api/maskis writer-gated, takes the verifier from the cookie, and asserts that only the.jsonchanged before committing.corpus.ts verify --as <login>is the repo-side equivalent and refuses a login without the capability rather than writing a block every read would ignore. A later save clears the verification: what a writer approved is not what is in the file any more. -
A submission by the OWNER through the contribution path is
contributor, notowner-verified. The one place the two paths differ in outcome for the same person, and it is deliberate: a proposal that promoted itself on the strength of who sent it would make the pipeline untestable by the person who most needs to test it. - Canon files grow the same block (
startedFrom,changed,author,verification,tier) with the weight semantics documented as narrower: a canon file is not training input, so the tier carries no numeric weight, andfrozenremains the stronger separate statement. A canon save never carries a verification forward — the file is a full snapshot, so the numbers a writer approved are not the numbers now in it. - No environment variables were added, so
DEPLOYMENT.mdis unchanged. -
data/foil-verification-map.jsongainsexemplarsByTierandpoolAwaitingVerificationin the code, but the committed artifact needs a Postgres-backed re-bake to pick them up. Not gated by CI, same as today.
Independent verification of the tier rework reproduced two UNDOCUMENTED shapes
that reach owner-verified weight from a hand-crafted fork PR, alongside the one
the entry above documented: (2) an author block byte-identical to
HISTORICAL_AUTHOR (login: cheyras, via: local-cli), and (3) a current-era
record whose version field is edited down to 3, which the historical
inference cannot distinguish from a genuine legacy record. Neither is reachable
from the App (both write paths compose sidecars server-side and hardcode
version); all three require a fork PR whose diff a human reads. The
residual-hole note in provenance.ts now names all three, the asAuthor
docstring no longer overstates what a shape check proves, and a dedicated test
pins each door as known-and-accepted so a behavior change surfaces as a test
failure rather than a silent doc drift. Review guidance: in any sidecar diff,
version, author, and verification are claims the diff itself cannot
prove.
Decided by: @cheyras
Decision: data/task-queue.json is a build step
(tools/build-task-queue.mts, every build, no database) that turns six
committed artifacts into one impact-sorted list of task cards, and the hosted
editor's landing page renders it. The leverage table stays as one section of
that list rather than being replaced — it is the impact spine, in its own
units.
Six sources, seven card types: approximated recipes (PATTERNS[].implemented),
canon-less patterns (corpus-manifest.json), standing verification nays (a new
committed datum), uncorrected machine masks ranked by 1 − diff.agreement,
window-scope groups at zero exemplars, untargeted research residuals, and the
bake's empty-pool diagnosis rendered verbatim.
Why: Every one of those was already recorded in this repository and
invisible from the home screen, so a contributor who could not draw a mask had
nothing to read. Generating rather than writing the list is what makes it
survive: there is no hardcoded task anywhere in the builder, and task.source
names the file and field each card came from, so a generated list cannot be
mistaken for one somebody made up.
Where a document states a COUNT, the builder derives the real number and emits
a reconciliation row either way — printing a FINDING: line when they
disagree, and not failing the build. The document is a claim; the corpus is
the measurement. Failing on every stale sentence would mean nobody could commit
a measurement until they had also rewritten the prose. This is the same
discipline build-corpus-manifest.mts already applied to the canon-less count,
generalised.
Implications:
-
Three doc counts are stale today, and the queue says so on the page.
"5 approximated types" (
SHADER-CONTRACT.md:290-295) is 1 — four of the R3 list shipped dedicated recipes in R3-MISC. "13 canon-less" is 12, the long-standingnonecorrection. "4 standing nays" (VERIFICATION.md:61-73) is 5, and only two of the original four are in it: R3-MOTION brokestarlight(:677), R3-GLYPH brokeradiant-collection-dots(:754), andradiant(:680),ace-spec(:756) andprismatic-pokeball(:757) were added.VERIFICATION.md:840-843's claim that "zeroapproxViafallbacks remain" is also refuted by the code —big-glitterstill carries one. -
data/verification-verdicts.jsonis new, and is the one thing that could not be derived. The verdicts areVERIFICATION.mdprose and their machine-readable form is outside the repository (:31-34), so the extraction is by hand, once, with doc line numbers per row. Update it in the same commit as any future wave; the builder refuses a row that names a non-pattern. Its$supersededClaimblock records the four-nay claim it replaces rather than deleting it, because other documents still make that claim. -
A still-frame-blind nay asks for a live tilt, not a shader round. The flag
is in the datum, it selects the
live-tiltskill, and the card renders the caveat in words.VERIFICATION.md:759-764names Chey's live tilt as the arbiter for motion claims;ace-specandradiantare pixel-refuted in the doc itself. Answering these with GLSL is the documented wrong move, so the queue says so where a contributor will read it. -
An outranked empty pool is NOT ranked by the printings it loses. Its
impactisnull, not 1,818. A higher-ranked row legitimately wins every one of those printings — cited rows routinely describe different physical layers of the same card — so ranking by that number would sort "usually nothing to fix" above real work and read as an invitation to go and win them back. The number stays on the card as detail, under the do-not-flip-a-winner guard. -
The queue lives in the BAKE directory, not next to the corpus manifest,
because two of its six inputs are bake outputs. It follows the same
FOILKIT_BAKEseamcopy-data.mjsandvite.config.tsuse, so the fixture site gets a real queue built from fixture numbers and the whole surface is exercisable with no database. A missing bake is recorded inbakedInputs, never fatal; a missing committed input is fatal. -
CI gains
build-task-queue.mts --check, beside the corpus manifest's, for the same reason: a committed queue that no longer matched its inputs would send contributors at work that is already done. - No environment variables were added, so
DEPLOYMENT.mdis unchanged.
Decided by: @cheyras
Decision: The step-11 plan asked whether the two catalog-closable residuals
— the six Holo Rare basic Energies of ex13 and ex16 resolving to
vertical-sheen-rainbow — could be closed from committed catalog data. They
already were, on 2026-08-08 in the R7 pass: both known_residuals entries
carry a resolved field naming the exact card ids (ex13-105..110,
ex16-103..108), and two cardIds rows in data/foil-card-assignments.json
assign the pattern. 55 residuals recorded, 12 closed, 43 open. No resolver
digest changed in this work, because no assignment row was edited.
A THIRD residual is closable the same way and was queued rather than closed:
ex11 (EX Delta Species) holo. Its recorded blocker is verbatim "the task-data
card list is missing so I cannot emit cardId selectors", and the committed
catalog shard now supplies it — data/catalog/sets/ex11.json names exactly
three Pokémon-ex (ex11-108..110, Flareon/Jolteon/Vaporeon ex) and three Gold
Stars (ex11-111..113, Groudon/Kyogre/Metagross Star) among its holo-tier
cards, matching the source claim's own enumeration ("Gold Stars, Pokemon-ex").
Why not close it here: the closure is an assignments edit, and
data/foil-verification-map.json and data/foil-pattern-cards.json — the two
artifacts the queue's leverage table, empty-pool diagnosis and every impact
number are built from — are bake-catalog.mts outputs that need Postgres. This
machine has no database. Committing the edit would have shipped an assignments
file inconsistent with the committed bake, in the same change that introduced a
surface whose contract is that every card traces to its data source. The
resolver digest is measurable without a database
(tools/parity/resolver-receipt.mjs), but a digest that moved with a stale
verification map beside it is a worse artifact than an open residual.
Implications:
- The
ex11card is in the queue asresidual:ex-holos:ex11:holo, research skill, carrying theneeds-a-bakeguard — which is why that guard exists. Every residual card carries it. - Whoever closes it should expect the guesses for those six cards to move from
mirrortocosmos, and the digest to change by exactly the printings those six cardIds carry across their holo-tier variants — 13 in the current catalog (ex11-108×2,ex11-109×4,ex11-110×4,ex11-111..113×1 each) — and nothing else.ex11-114Azumarill is a secret rare the cited claim's enumeration does not cover, so it stays under themirrorrow and the residual closes PARTIALLY, the same shapeex13'sresolvednote already uses. - The other 42 open residuals were checked and are blocked by something the
catalog cannot supply: a source that names cards (
ex14's three Holo Rare Masters sit in unparsed image captions), a rarity split the catalog does not carry, or the absence of a vocabulary slug entirely (dc1,sm3.5) — which no amount of catalog data closes.
2026-09-06 — Correction: Azumarill's #114/113 is a secret rare by numbering, not by the catalog's rarity field
Decided by: Claude Fable 5 on behalf of @cheyras
Correction: The entry directly above ("The ex13/ex16 residual closure…")
called ex11-114 Azumarill "a secret rare" in the same sentence that cites the
catalog rarity field — but data/catalog/sets/ex11.json records that field as
"Rare", not "Secret Rare". The card is the set's secret rare by its number
(#114/113, one past the set's 113-card printed count — the standard
convention this catalog does not encode as a distinct rarity string), and the
sentence should have said so instead of stating the catalog agrees.
Why: DECISIONS.md is append-only, including for same-week entries — a correction is appended rather than the original being edited, per AGENTS.md.
Implications: None to the ex11 residual's PARTIAL-closure boundary or to
any code — this only corrects how the reason Azumarill sits outside it was
described.
2026-09-06 — The live-tilt guard is keyed on ask, not stillFrameBlind; two stale doc claims corrected at their own sites; the uncanoned citation now points at a claim, not its fix
Decided by: Claude Fable 5 on behalf of @cheyras
Decision (three fixes, one commit):
-
tools/task-queue/build.ts's verdict guard was keyed on the wrong field.stillFrameBlind ? ['live-tilt-not-glsl'] : []put the full "this is a LIVE-TILT verdict, not another GLSL round" guard onenergy-symbols, whoseaskisglsl(it needs the 9-icon atlas) even though it is also still-frame-blind (the motion half of its nay is pixel-refuted). The card read "GLSL · hours" directly above a guard telling the reader not to do GLSL work — a contradiction on the structural pair the datum already distinguishes (askvs.stillFrameBlind). The guard is now keyed onask: the full guard applies only whenask === 'live-tilt'; a row whoseaskisglslbut is still still-frame-blind gets a new, softer guard,motion-half-needs-tilt, saying the motion half needs a separate live-tilt verdict rather than telling the reader to skip the GLSL ask. -
The
uncanonedreconciliation row cited the wrong site.claimedAtpointed atdocs/HOSTED-EDITOR.md:195-200, which is the CORRECTION ("The number is 12, not 13"), not a place that still claims 13. Nothing indocs/still asserts 13 as live; the only surviving mention is historical, in this file (DECISIONS.md:2062-2065, the 2026-09-06 task-queue entry restating "13 canon-less is 12").claimedAtnow cites that entry and sayshistorical recordexplicitly, so a reader is not sent to the fix when looking for the claim. -
Two doc counts the queue reconciles itself against were fixed at their
claim sites, and the reconciliation datum updated to match.
docs/VERIFICATION.md:73anddocs/SHADER-CONTRACT.md:295each gained a same-line, dated correction (no new lines inserted, so every downstreamdocs/VERIFICATION.md:NNNanddocs/SHADER-CONTRACT.md:NNNcitation indata/verification-verdicts.jsonand elsewhere still points at the right line): the approximated-type count is 1 (big-glitter), not 5, and the standing-nay count is 5 (ace-spec,energy-symbols,pokeball-hologram,prismatic-pokeball,radiant), not 4.CLAIMED_APPROXIMATIONSandCLAIMED_STANDING_NAYSinbuild.tsnow hold the corrected values, so both reconciliation rows readagrees: trueand no longer appear in--check's FINDING output.uncanonedis the one row that is NOT fixed — it never will be, because it is not a claim that drifted, it is subtask 5's arithmetic bug (45 − 32countsnone, which has no canon by definition) preserved on purpose as a permanent record.
Why: A guard that contradicts the ask sitting right above it teaches a
contributor the wrong lesson on the first card they read. A claimedAt that
cites the fix instead of the claim sends a reader hunting in the wrong
direction. And a reconciliation mechanism whose whole point is "say so in the
same commit" when a doc gets fixed cannot itself go on reporting a fixed doc
as a finding — build.test.ts says exactly this in its own assertion
message, and until now, the code did not.
Implications:
-
tools/task-queue/build.tsgained oneGUARDSentry (motion-half-needs-tilt) and reworded the twoCLAIMED_*constants and their reconciliationclaim/notetext to state they were corrected at their own sites, with the date. -
tools/task-queue/build.test.tsupdated: theenergy-symbolsfixture now carriesstillFrameBlind: true(matching the real corpus) so the guard split is exercised in the synthetic corpus, not only the real one; the synthetic reconciliation test's expected claim text moved from "4 standing nays" to "5" (the constant is a doc claim, not corpus-derived, so it does not vary between the synthetic and real corpus); and the real-corpus "three stale doc counts" test is now "two fixed, one permanent" and asserts theuncanonedrow'sclaimedAtno longer points at the correction text. -
data/task-queue.jsonregenerated;--checkpasses with exactly one FINDING (uncanoned). - No environment variables were added, so
DEPLOYMENT.mdis unchanged.
Decided by: Claude Fable 5 on behalf of @cheyras (Project Holo subtask 12)
Decision: The canon lab's reference pane — an empty slot since subtask 2
removed the committed clip.webm + 8 keyframes per pattern — is filled with a
click-to-load, manually looped YouTube embed of the same seconds of the same
source video. The citations that make it possible are promoted out of prose into
packages/patterns/src/reference-clips.json, generated from
reference/<slug>/notes.md by tools/build-reference-clips.mts and gated by a
CI --check. Five decisions inside that, each taken deliberately:
-
The datum lives in
packages/patterns, notdata/. It is imported by the bundle, so it needs no route, no fetch and no bake step — the pane has no API at all, and/referenceis gone fromapi.tsalong with its empty-index stub. It is MIT under thepackages/**glob rather than CC0: these are citations of third-party video, not measurements of a printing, which is the same treatmentreference/**already givesMANIFEST.json. Shape is{ videoId, chapter, clipStart, clipEnd }per dir plus a source table carrying each video's title and creator for the attribution line. -
The loop is driven, not declared.
loop=1needsplaylist=<id>, has limited support in IFrame embeds, and with astart/endpair restarts the WHOLE video rather than the segment — 3.5 seconds of reference followed by a 26-minute intro, forever.start/endare also integer-only and the corpus is not (radiant-collection-dotsis 1257.5s–1261.0s). Soapps/editor/src/reference/clipLoop.tspollsgetCurrentTime()andseekTo()s the fractional start when playback passes the end. -
youtube-nocookie.com, and it stays that way structurally. The IFrame API offers ahostoption for this, but that leaves the domain to a config key somebody can drop. The pane renders its OWN iframe at the nocookie url and hands the element tonew YT.Player(element)to adopt — the API reads the origin off the element'ssrc, so the domain is a property of markup this repository controls. Verified end to end in the E2E run. The API script itself has no nocookie copy and does come fromwww.youtube.com; that is stated inytApi.tsrather than glossed. -
Click-to-load, with a LOCAL placeholder. No iframe exists until somebody presses play. The obvious placeholder — the video's own thumbnail from
i.ytimg.com— was rejected: it is the same third-party request on page load wearing a different hostname. The placeholder is our own markup carrying the datum's title, chapter and bounds as text. -
No CSP change was needed, and that is a finding rather than a non-event. There is no
Content-Security-Policyanywhere in this deployment; the security headers intools/build-functions.mtsarex-content-type-options,referrer-policyandx-frame-options: DENY(which governs this site being framed, not this site framing YouTube). Nothing blocks the iframe. A future CSP must carryframe-src https://www.youtube-nocookie.comandscript-src https://www.youtube.com, and there is a note at that site saying so. No headers and no environment variables changed, soDEPLOYMENT.mdis unchanged.
Why: Removing the vendored clips was right — that footage is cut from other people's videos and foilkit cites rather than vendors (AGENTS.md F2) — but it left a contributor tuning a shader against a memory of the reference. Embedding from the source restores the comparison without a byte of third-party media in the repository, and it sends the creator real views, which is the right way round rather than a consolation. Driving the loop by hand is the only way to show the four seconds the notes actually cite.
Implications:
-
TWO CONSUMERS, TWO PATHS, AND THEY CANNOT BE UNIFIED. The embed and the
local frames now start from the same three numbers in the same notes.md, and
the next reader will try to make one serve the other. It cannot be done in the
direction that matters: a YouTube iframe is cross-origin and no browser API
returns a pixel from it. The embed is for human eyes; the Gemini
articulation passes (
reference/pipeline/gemini_vision.py) and the frame-diff harness read local frames thatreference/fetch-reference.shwrites into gitignoredreference-media/. Deleting either path does not make the other cover its work. The full argument is in the header oftools/reference-clips/notes.ts, pointed at from the datum's$doc, fromreference-clips.tsand fromfetch-reference.sh. -
reference/manifest.mjsis now a SECOND parser over the same prose, forfetch-reference.sh's plan. Two parsers over one hand-written format drift, so the builder re-readsMANIFEST.jsonand refuses to write if any video id, chapter or bound disagrees. CI runs it as--check. Note the difference from the corpus manifest and the task queue: this output is a source file, not a baked artifact, so no build step regenerates it and nothing else would ever notice it going stale. - A heading that says
clip:followed by something unreadable is refused by name rather than degrading to "no clip recorded" — which would put the pane's honest empty state in front of a contributor as a lie about a clip that exists. -
The counts, measured rather than assumed. 44 pattern dirs, 5 source
videos, 43 with a clip range. The one without is
_interlude-what-is-a-holo, the physics segment, from which nothing was ever cut — and no pattern maps to it. Of the 45 entries inPATTERNS, 44 get an embed and exactly one does not:none, the plain-card baseline, which has no foil to film. (reverse-sheetborrowspokeball-masterball's footage, and the pane says so.) The subtask brief anticipated "13+ patterns may lack one"; that number is the count of canon-less patterns fromdocs/HOSTED-EDITOR.md§3, a different quantity entirely. - The pane is on both surfaces: the canon lab leads with it, card adjust
carries it behind a closed
<details>. Reuse was one import, so "CanonLab only" would have been a decision made by laziness rather than taken. - Attribution is a credit and a link, and claims no permission. Contacting the creator is the manager's item, not this one, and a line reading "linked with permission" would be a false statement about a real person on a public site.
- Suites: 538 unit (was 506 — 19 parser, 13 loop controller), 81 editor
E2E (was 58), 30 stage acceptance (unchanged). No CI job was added; one
step was, next to the two existing
--checksteps. -
Verified against the live API, not only against a mock (AGENTS.md F7). The
built site was served locally and driven with a real network: before the
click, zero requests to any Google host; after it, the real
iframe_apiscript adopted our ownyoutube-nocookie.comiframe and firedonReady— which is the confirmation that the adopt-an-existing-iframe route works with the nocookie domain, and the reason thehostoption is not used.getCurrentTime()sampled every 500ms for 20s gave 40/40 samples inside 45.6s–49.17s with four clean wraps (48.72 → 45.63,49.17 → 45.93,49.09 → 45.85,48.98 → 45.60) oncosmos, whose recorded bounds are 45.6s–49.1s. The loop holds the segment on the real player. - One thing that cost an hour and is worth writing down: the real
YT.Playerattaches its methods after construction. An instrumentation wrapper that capturedinst.seekToat construction time capturedundefinedand installed a permanent no-op — the loop then reported five successful seeks while playback ran away to 62s. Nothing in the shipped code does this; the harness did. If you go to instrument this player, observe it, do not replace it.
Decided by: Claude Fable 5 on behalf of @cheyras (Project Holo, subtask 13)
Decision: A reverse holo is modelled as TWO orthogonal layers rather than
one. The FOIL — embossed micro-texture in aluminium — stays a procedural
GLSL recipe in packages/patterns, which is correct because it is physics. The
DESIGN — opaque ink printed over that sheet, blocking it — becomes DATA:
data/ink-designs.json + data/ink-tiles/, resolved per printing by a parallel
resolveInk on (scope, type, variantKind) + rarity, resolving
card > subset > set > era, and entering the composite as coverage
(m *= 1 − uInkStrength·inkCoverage(uv)). Full write-up: docs/INK-DESIGN.md.
Why: Procedural will never converge on the design because there is nothing
to converge to. Sceptile and Magneton, and a dozen other reverses that look
nothing alike, sit on the same vertical-sheen sheet. Tuning a recipe toward
that set approaches the average of a set with no average, which is why
"patterned reverse holos look really bad" is a category error and not a tuning
gap. The resolver already half-knew this and discarded the knowledge: both tiers
carry a penaltyOf demoting mirror rows on reverses because the corpus
records them as ink-design evidence, not foil evidence. This tier is where
that evidence goes instead of the floor.
The commissioned bug was checked first, and REFUTED — with numbers. The
hypothesis was that reverse-sheet draws its ring+dot grid over a scan that
already contains the printed design. Method: autocorrelation of high-passed luma
over the sheet band, lags 4–80 px, n = 7 TCGdex high.png scans at 600×825.
Instrument validated first on a positive control — the same scan with
reverse-sheet's own lattice stamped in at uP0 = 11 — which returned lag 54 px
at SNR 7.85, the predicted 600/11 = 54.5. Every real scan returned SNR
1.41–1.88 with peaks at 7–14 px, the adjacency length of the high-pass
residual. Two of the seven (basep-33, pl1-SH4) are cards with no
non-reverse printing at all, so their scan can only be of a reverse, and they
read the same as the rest. Catalog scans do not carry a printed reverse design;
they are the normal printing, per the TCGdex convention that one image serves
every variant (sv08.5-040 serves six).
Three things survived the refutation and are why the tier shipped anyway:
-
The real defect is worse than double-drawing. The design is drawn from
nothing at all — a lattice at a density nobody measured, at uniform spacing
over the whole sheet — and then
inkGlyph/inkDetail, which read local contrast, punch holes in it wherever the printed text happens to fall. That is misregistration, just not the predicted kind. -
The double-draw hazard was real and completely unguarded. Nothing
consulted
shows. The first image that does carry a reverse printing — subtask 14's community captures, a re-shoot, one of the 29 reverse-only cards getting a better scan — would have been drawn on twice with nothing to stop it.uInkDrawis that guard, and it is why the guard shipped even though the bug did not reproduce. -
The measurement is itself the answer to what
shows: unknownshould do.
shows: unknown DRAWS, and the assumption is written down. frames.json
reads unknown on all 24 records and its own $doc says that is the finding,
not a gap — derived, never claimed — and sets the rule: only a MEASURED
reverse may suppress an overlay. So the default is normal, justified by the
n = 7 measurement above and by the TCGdex one-image-per-card convention, and
stated as an assumption in data/ink-designs.json's frameShows block. The
per-frame override lives there rather than in frames.json, which is generated
and must not be hand-edited; it is a human ratchet (F4), and it is empty
today, honestly — nothing has been measured to reverse, and inventing an
entry would be exactly the claim frames.json refuses to make.
NO composite-contract bump, and it is proven rather than argued. The
contract versions the law that turns a stored uniform snapshot into pixels, and
no stored snapshot changed meaning: every ink uniform is STRUCTURAL
(surface-owned, never in a canon file), uInkOn defaults to 0, and every added
instruction sits inside its branch. tools/parity rendered all 45 recipes on the
blank base before and after: 45/45 byte-identical, against a control pair
that was itself 45/45. No canon AE recheck was owed, and none was run.
The 3b handoff is the keying evidence, and it is cited on the rows.
holo-archive/3b-pairs/delta-classes.md measured variantKind — not scope — as
the discriminating term (the Xerneas pair is holo-against-reverse, both foiled,
and the tag treatment tracked the reverse printing), and era-research.md
supplied the three delta classes and the rarity gate.
tools/rectifier/THRESHOLDS.md records why the numeric classifier cannot be
trusted on photographs (CHANGED_PIXEL_DELTA = 24 calls 40.8% of a
visually-black diff "changed"), so the classes ride along on each row as a cited
delta rather than as something this tier recomputes.
Implications:
-
Four tiles shipped, six marks queued. Shipped:
dot-grid,ring-dot,pinstripe-diagonal,crosshatch— generic lattice geometry, uncopyrightable, every coordinate a round percentage of the cell. Queued with the slot EMPTY:pokeball,masterball,specialty-balls,plasma-shield,energy-symbols,ex-set-logos. A traced Poké Ball is TPCi's design whoever ran the tracer, and a CC0 dedication over it would be worth nothing (F2). A queued slot leavesuInkOn0, so those printings render exactly what they render today — the count going to zero is not the goal; every queued mark carrying its caution is. -
data/stays CC0 and our own tiles belong in it. Measured placement numbers are facts; the tile geometry is ours outright, so we have the standing to dedicate it. Covered by the existingdata/**glob with a notice file (data/ink-tiles/INK-TILES-NOTICE.md, five sections, rejection trail included).ink-index.jsonjoined the CC0 override list beside the other two derived resolver tables. -
The period is measured and WEAK, and says so.
across = 11.7comes from one x-peak at 43 canonical px on one of 3b's four rectified pairs, at SNR 2.1, on pairs 3b itself records as misregistered. Recorded withn,snr,confidence: lowand acaveat; the two unmeasured tiles carryn: 0andconfidence: nullrather than a number with no provenance (F5). The slider surface exists so a human can correct it, and that correction is a decision this file may never overwrite. -
ex-set-logosis recorded but NOT expressible. A set-name logo stamped bottom-right inside the art box is a single positioned stamp, not a lattice. It is queued so it is not lost; the second positioned layer is separate work. -
plasma-shieldis queued WITHOUT a row, because the catalog declares onereversekind for bw8/9/10 and carries no Team Plasma flag. A set-wide row would be wrong for most of the set. The gap is visible; the wrong answer is not. -
The task queue has a seventh source, a new
artskill and a neworiginals-onlyguard. 108 tasks → 114. A queued tile is sized only when EVERY row queuing it can be counted — one uncountable row makes the number a lower bound, and a lower bound presented as an impact ranks the card below work it outweighs. -
The resolver got its first tests, covering the ink axis only. Pinning the
foil axis retroactively in the same commit would make a future regression
ambiguous about which axis moved;
resolver-receipt.mjsalready digests 10,312 foil probes. -
The first render of the layer was WRONG, and only looking at it found that
(F7). Every test passed and parity was 45/45 — because the bug lived entirely
on the SCAN path, which the blank-base harness does not exercise. The design
painted straight across the ART WINDOW:
sheetscope is the era rect INVERTED, so the mask is 0 over the illustration, and ink printed on a foil sheet cannot exist where there is no sheet. Fixed by scoping the coverage to the mask before the mask is reduced by it (inkCoverage(uv) · uInkStrength · m). Measured after the fix on a real SV reverse scan, split by region: art window meanAE 0.091 (1.2% changed — the mask feather edge) against sheet meanAE 8.82 (43.5% changed). The mask term is now pinned by a test so it cannot come back silently. The lesson is reusable: a blank-base parity pass proves a no-op, and proves nothing at all about a layer that only exists on a scan. -
A second thing only the render found:
tools/parity/serve.mjshad no.svgMIME entry, so a tile 200'd asapplication/octet-streamand the<img>silently refused it — coverage 0, procedural fallback, nothing in the console. A present tile read exactly like an absent one. The editor's vite middleware already carried the entry; this server did not. -
Render evidence, one real SV reverse scan (
sv03.5-095), meanAE over the sheet region: plain scan → today's render 13.07; today's render → the tier on with nothing to draw 1.56 (that delta IS the procedural ring+dot guess disappearing, in isolation); today's render → the registered design 8.82. The art window moves 0.015–0.091 in every comparison, which is the mask edge. -
Suites: 561 unit (was 538 — 6 core, 3 patterns, 14 resolver), 81 editor
E2E, 30 stage acceptance, 82 function — all unchanged and all green.
One CI step added next to the three existing
--checksteps. -
Deviation, declared: the ink TILES were authored; a tile-authoring
surface was not built. The editor gets the placement sliders and the state
readout (design / queued / in-scan / none), which is what a human needs to
correct a measurement. Drawing a new lattice cell is a file in
data/ink-tiles/plus a notice section, done in an editor — and the queue cards deliberately carrylink: nullrather than inventing a route so the card had a button.
2026-09-07 — The queue page must survive a datum newer than itself, and the ink tier must draw nothing where it knows nothing
Decided by: Claude Fable 5 on behalf of @cheyras (Project Holo, subtask 13, adversarial verification round)
Decision: Two rules, each one generalised from a defect the entry above shipped.
(1) A generated artifact fetched at runtime may contain ids this client has
never heard of, and every lookup against one must degrade rather than throw.
apps/editor/src/queue/taskQueue.ts widens its type/skill/estimate ids to admit
any string, routes every label through a degrading helper, and builds its filter
chips from the task list rather than from the build-time order so a new kind
gets a control instead of having its cards hidden behind one that does not
exist.
(2) The ink tier draws nothing where no row keys the printing. The era
lookup's ?? 'modern-sv' fallback is gone; an unmapped series resolves to
state: 'none'.
Why: The queue went WHITE in production. data/task-queue.json gained a
seventh source with a new ink-tile type and an art skill; the SPA's union
had neither; TYPE_LABEL[task.type] came back undefined; reading .what off it
threw during render and React unmounted the tree. The editor's landing page —
the surface this whole subtask exists to fill — showed nothing at all, while
every test in this repository passed and the E2E reported 81 green assertions.
It passed because the FIXTURE could not size an ink-tile card: the registry keys
its queued rows on real set ids (sv08.5 + reverse-foil-pokeball) that a
synthetic catalog does not carry, so all six baked as impact: null, sorted to
the bottom, and landed at positions 60–65 — past the 20-card first screen, never
rendered. Production ranks two of them at 10 and 11. CI was watching the exact
bug and could not see it.
The second rule has the same shape one layer down. ERA_BY_SERIES[slug] ?? 'modern-sv' gave every series era-layouts.json does not cover — thirteen of
the catalog's twenty-one, 6,963 of 13,165 reverse printings, 52.9% — the
Scarlet & Violet dot grid at strength 0.8, on no evidence at all. That is the
drawn-from-nothing defect the entry above is about, reborn in the tier that
was supposed to end it.
Implications:
-
The fixture now carries the ink registry's real coordinates, four sets
across two overlap series, on the same F2 split
base1already uses: real ids, invented names. Its ink cards rank 2, 4 and 5 — inside the first screen, as production's are. CI must see the surface production sees, and a fixture that ranks differently from the real artifact is not a fixture of it. - And the E2E presses "Show the other N", asserting one card per task in the artifact, a non-empty badge and a skill line on each, a chip for the new skill, and NO pageerror — a React unmount is not a console error, which is the other reason the run stayed green. That half holds regardless of ranking. 81 → 93 assertions.
-
Own-property lookups, not
?? fallback.SKILL_LABEL['toString']inherits a function offObject.prototypeand React throws on a function child, so the obvious guard would have restored the white screen for one adversarial slug. The tests name ids no builder will ever emit; a test written against the next real id only pins the last mistake. -
A null tile now means three things and says which.
queued(a trademark we may not trace),no-ink(a recorded decision that none is wanted),none(nothing keys this printing). Legendary Collection returnedstate: 'queued', queued: null— a shapeInkDesignRef's own doc forbids — and so spent a release announcing itself as work nobody owed. -
The tier ladder is strict. Scope weights ×100, so no accumulation of
per-axis bonuses can lift an era row into a tie with a bare set row and hand
the decision to
confidence. Specificity and evidence quality are different claims. Latent, unreachable from today's registry, and pinned by scoring the pair directly rather than by waiting for a row that would produce it. -
The rarity gate matches a whole rarity.
["rare"]as a substring was true of "Ultra Rare", "Secret Rare", "Rare Holo LV.X" and eleven more strings this catalog carries. Spelling variants are listed explicitly — a list is reviewable, a fuzzy match is not. -
Two shipped tiles are reachable from no row, which is legitimate and is
now DECLARED (
unkeyedTiles, with the reason each is unkeyed) and cross-checked against the rows in both directions. An unreachable asset that nothing explains gets deleted as dead weight or keyed to an era on a hunch. -
pinstripe-diagonal.svgdid not tile. Its corner-wrap triangles had legs of 13 where the band's half-width is 26: mean |Δcoverage| 66/255 across the cell's own wrap — a grid line down every card it was used on, in a file whose owndescsaid "tiles seamlessly". Every tile is measured now (tools/ink-tile-seam.mjs, an analytic sampler over the SVG subset the tiles use, no rasteriser and no dependency), only 0 passes, the number is committed into the index, and a tile the sampler cannot read is a hard failure rather than a silent zero. -
uInkOnfalse for a null tile is asserted where it is decided. The invariant spans two packages and the stage CANNOT enforce it —on: truewith no texture is also legal there and meansin-scan, the opposite thing — so the assertion lives in the resolver, is driven off the index so tomorrow's row is covered, andstage.tscarries a comment saying why it does not second-guess the answer it is handed. - CI's test-count comment is deleted rather than updated. It said 506 and the suite was 572. A number in a comment nobody can fail on reads as a commitment while being a fossil.
-
Suites: 572 unit (was 561 — 5 editor queue, 6 resolver), 93 editor E2E
(was 81), 30 stage acceptance, 82 function. Two new
--checkfailure modes on the existing ink-index gate: the tile seam, and tile ↔ row in both directions.
Correction to the entry above, and a re-measurement that did not find one.
Its "Suites: 561 unit (was 538 …)" line was challenged as reading 533. It
does not: the suite was re-run at 54a3b9c, the commit immediately before this
subtask's first, and measured 538, with the delta 6 core + 3 patterns + 14
resolver = 23 exactly accounting for 561. tools/task-queue/build.test.ts held
20 tests before and after and contributed none of it. The line stands as
written; this note records that it was checked rather than leaving the challenge
standing against it.
Correction to the refutation's strength claim, which does not move the
verdict. The entry above reports "every real scan returned SNR 1.41–1.88" as a
flat range, which reads as though the instrument had uniform sensitivity across
the seven. It does not: an adversarial re-measurement of that harness (2026-09-07;
reported, not re-run here) puts the per-scan detection floor as low as roughly
20% stamp amplitude on basep-33 and higher on others, and notes that the
palette/Adam7 encoding of some TCGdex PNGs interacts with the high-pass residual.
The conclusion is unaffected — the positive control returned SNR 7.85 at the
predicted period and every real scan returned peaks at the high-pass adjacency
length instead — but the per-scan SNR figures are a property of scan and encoding
as much as of the printing, and should not be read as "how much design each scan
carries".
Decided by: Chey Rasmussen, implemented by Claude Opus 4.6
Decision: Prim becomes LinePrim | ArcPrim | CubicPrim. The stored vector
language keeps lines and arcs exactly as they were and adds
{ k: 'cubic', c1, c2, to }, with control points in the same fractional,
y-down space as every other coordinate.
Why: #17 asks for a pen tool that authors vector masks, and specifies both
"bezier handles are a click-drag gesture" and "store as VPath primitives" --
which the language could not do, because it had no bezier. The entry that
established the two-primitive language argued a bezier is "more general and less
checkable — an arc has a radius you can read and argue with". That argument was
about a FITTER's output, where a curve nobody chose is harder to audit than a
radius somebody can measure. It does not carry to a curve a human placed on
purpose: the reason #17 wants vector at all is that "a vector diff is readable
text in a pull request", and six numbers in a diff satisfy that as well as three
do. The alternative — beziers while editing, arcs on save — reintroduces
exactly the lossy round trip the task exists to remove.
Implications:
- The pen emits a plain
LinePrimwhenever both adjacent handles are retracted, so straight segments stay in the old language and the committed corpus is not churned into curves that merely look straight.data/vector-templates.jsonis untouched and still fits and rasterises bit-identically. -
The trap this created, and how it is now closed. Every consumer that
branched on primitive kind did so with a ternary —
pr.k === 'line' ? .. : ..— whose else-branch silently means "arc". A third kind makes each of those a wrong answer that TypeScript cannot see, because a ternary's else-branch was never asked to be exhaustive. All four sites are nowswitchstatements with anever-typed default, so a FOURTH primitive is a compile error rather than a silent misrender.toPxandnormwere doing the same job and were collapsed into one exportedmapPathCoords, because "scaledto, forgotc1/c2" had two places to hide; a test rasterises the bug's own output alongside the right one and asserts they disagree. - Cubic flattening uses the convex-hull bound rather than the textbook
3/4 x max(d1,d2)flatness test. The 3/4 factor is only valid when both handles project onto the chord, and a pen produces the other case constantly -- drag a handle back past its anchor and the cheap test calls a cusp flat. -
pointType(smooth vs corner) is deliberately NOT stored yet. It is Illustrator's stored flag, not a derived property, and a corner may legitimately carry two collinear handles; the plan is an optional one-character field per primitive when the editor persists pen masks, so that a corner does not silently become a smooth point the first time someone drags its handle.
Decided by: Chey Rasmussen, implemented by Claude Opus 4.6
Decision: @foilkit/forge gains a ./geometry subpath exporting only the
modules that are free of node builtins — the vector language, the pen geometry
and engine, the shared rasteriser, the contour tracer. tools/check-geometry-browser-safe.mjs
walks the import graph from it on every push and fails if any reachable module
gains a value import of a node: builtin. A statement-level import type stays
legal, which is what keeps edge-trace -> png.ts allowed.
Why: the barrel pulls node:fs, node:zlib and node:child_process, so the
editor could not import forge at all. The existing answer was to hand-port the
functions it needed into apps/editor/src/staging/provisionalDiff.ts and keep a
byte-parity test in step. That is a reasonable answer for two functions and a
terrible one for a pen tool, which needs the snapping and the rasteriser to be
the same code — a second implementation of a rasteriser is a second answer to
"where exactly is this mask's edge", and the whole point of the corpus is that
there is one.
Implications:
- The pen's live preview rasterises through
rasterizePolygons, the same function templates and masks already go through, so what the shader shows while drawing is what gets committed. - The guard is proven non-vacuous rather than assumed: injecting a
node:fsimport three modules down fails it, and narrowing that same import toimport typepasses it. -
README.md's line saying every forge import is a node builtin was the exact sentence that would send the next person back to hand-porting. It now names the subpath and the guard.
Decided by: Chey Rasmussen, implemented by Claude Opus 4.6
Decision: #17's pen tool is built to Adobe Illustrator's interaction model deliberately and specifically — same gestures, same modifier semantics, same keyboard bindings — rather than to a reasonable-looking approximation. The behaviour was researched into a 126-item conformance checklist first, and the checklist is the acceptance criteria: engine tests are named after the items they prove.
Why: the person who has to use this tool lives in Illustrator. A pen that is 90% right is worse than one that is obviously different, because the missing 10% is discovered one muscle-memory failure at a time, mid-task, forever. Deciding fidelity by argument was not an option either — most of what makes the tool feel right is undocumented folklore — so it was verified against Adobe's own reference material, with the parts that could not be verified flagged rather than guessed.
Implications:
-
The engine is headless.
pen-engine.tshas no DOM, no React, no canvas: it isreduce(state, input, cfg)over a plain serialisable state, driven in tests by synthetic events. "Feels like Illustrator" is a claim about behaviour, and behaviour that can only be exercised through a browser barely gets exercised. The React surface is deliberately dumb — the cursor comes fromcursorFor, the handle stubs fromvisibleHandles, the bindings from one exported table. - The two smooth-point rules are kept apart, because collapsing them is the single most common way a bezier editor feels wrong: handles are created MIRRORED at placement, but on a later edit dragging one handle rotates the opposite to stay collinear while KEEPING ITS OWN LENGTH.
-
pointTypeis stored, never inferred. A corner may legitimately carry two collinear handles. The stored language had no room for the flag, so each primitive gained an optionalt. It names the anchor a primitive LANDS ON, which means underreversePathevery flag moves one slot — copying them across with their primitives yields identical shape, identical pixels, and permanently wrong editing behaviour. Both that and themapPathCoordsequivalent are tested. -
Deliberate deviations, all of them flagged rather than silent: Illustrator's
arrow-key nudge breaks the pen's connection to the active path, which its own
users call a bug — ours does not, and the flag to restore it is tested in both
positions. Illustrator's
+/-select separate tools; we have no tool slot for them, so they act on the selection. And the host editor bound bare+/-/0to zoom, so the pen claims+/-while it is active and hands them straight back when it is not. - We do not own Ctrl+0 / Ctrl+= / Ctrl+-. They are Illustrator's zoom bindings and also Chrome's, and a page cannot reliably take them. They are wired best-effort; the wheel and the ZoomHud remain the reliable path, and the comment says so rather than implying we won that fight.
-
No Adobe cursor art. F2 forbids tracing their glyphs, so
cursorFor's ten states render as the nearest standard CSS cursor plus adata-pen-cursorattribute that the acceptance run asserts on. The states are semantically distinct and not yet visually distinct; adding art later is one table.
Decided by: Chey Rasmussen, implemented by Claude Opus 4.6
Decision: a submission carrying both a mask PNG and pen-authored paths is
refused unless the server rasterises the paths and finds they agree with the
pixels: IoU >= 0.98 and boundary p95 <= 2px, checked in
functions/_lib/validate.ts and called from both the contribution route and the
direct-write route. A raster-only save REMOVES any existing .paths.json rather
than leaving it.
Why: the entire justification for storing vector is that a reviewer can read
the change. A .paths.json that no longer describes the mask beside it is a
confident, legible, wrong description — and it is worse than the binary blob it
replaced, because a reviewer would believe it. This is F3 in its usual form:
derived from the artifact, never taken from what the caller asserted.
Implications:
- The two thresholds do different jobs and both are needed. IoU is the area backstop and must tolerate two honest rasterisers disagreeing along the antialiased rim, so its floor sits just outside a full pixel of systematic boundary offset. Boundary p95 is the locality measure, and it exists because IoU dilutes: dragging one anchor of a 2000px boundary costs a fraction of a percent of area, which a floor loose enough for antialiasing would never catch. Measured, not guessed: a 0.9px whole-boundary offset passes on IoU, one handle moved 10px is refused on p95.
- Stated limitation: a displacement confined to under ~5% of the boundary passes p95 by definition. The check proves the paths are a way to reach these pixels to within a pixel or two everywhere, not the only way.
- The check rasterises through the same
rasterizePolygonsthe editor previews with. A second rasteriser would put the two sides of the comparison a fraction of a pixel apart everywhere, and the tolerance would then be covering the disagreement between rasterisers rather than the one that matters. - The frame migration removes vectors rather than rescaling them: 490x674 -> 504x704 is anisotropic, and an arc under anisotropic scale is an ellipse this language cannot express. The archive keeps a verbatim copy, so a revert restores.
Decided by: Chey Rasmussen, implemented by Claude Opus 5
Decision: pen-engine has always accepted an injected SnapFn and policed
it with maxSnapMovePx, and nothing was ever injected — DEFAULT_PEN_CONFIG.snap
shipped null, so the pen could not catch the printed edge a person was visibly
tracing, and constructionAngles was declared, defaulted, asserted in a test and
read by no code. Three things close that:
-
forge/pen-snap.ts, a pure provider built on the detectors that already exist. It preparesedge-trace's colour structure tensor ONCE per scan in canonical mask space and then answers point queries in microseconds, in the spec's priority order: an existing anchor a human placed, a printed-edge INTERSECTION, then the nearest point on a printed edge. -
A refusal channel.
SnapFnmay now return{ refused }as well as a proposal ornull, and the reason lands instate.snapRefusal. Two comparable parallel ridges withinline-snap's ownambiguityRatioof 0.85 mean the scan says "you are near some edges", not "you meant THAT edge", and the point does not move. -
Ctrl+U (I.102) as the master switch, in the engine's binding table and in
PenState, with a visible control on the surface that dispatches the same binding rather than a private code path. Edge snapping ships ON;constructionAngles— now actually read, at the two placement sites where Shift is read, with its own screen-px tolerance — ships OFF, as Illustrator's Construction Guides do.
Why: the acceptance criterion for the pen was "trace a rounded-rect foil
window on a real scan, watch the segments snap to the printed edges, save it as a
vector mask". Every clause of that was true except the middle one, and the middle
one is the reason a pen beats a brush on a scan. The guardrail is not
negotiable in the process: line-snap's test says an ambiguous band may nudge a
line and never relocate it, and a pen that obeyed an unbounded snap callback
would have been the hole in that.
Implications:
-
TWO INDEPENDENT LAYERS, and the test file says so. The provider refuses on
the EVIDENCE ("two comparable ridges"); the engine refuses on the DISPLACEMENT
("3.5 screen px, over the 2px limit"). The engine is never told whether the
provider feels confident, and
pen-snap.test.tsdrives a REAL provider aimed at a real edge too far away to prove the second layer holds without the first. -
Measured, with its n (
tools/measure-pen-snap.mts, synthetic cards whose edge coordinates are known exactly, because on a real scan the "true" edge is itself an estimate). Edges, n=360 queries per condition: 100% answered, residual mean 0.047px on a crisp step, and unchanged through a 3px Gaussian blur; with +/-6 noise, 0.299px. Corners, n=64: on a crisp step 52 answercornerwith mean residual 0.435px, and by a 3px blur NONE do — every one degrades to the edge snap it can still see rather than to a corner it cannot. On a card with no edges at all, n=360: zero proposals, on flat grey and on flat grey with +/-14 noise. - Stated limitation. Edge snapping is the robust half; corner detection is the fragile half and vanishes as a corner rounds off. The blur sweep is a stand-in for scan quality and nothing more — it says nothing about halftone rosettes, JPEG ringing, or foil blowing out under a flash.
-
A degenerate scan gets a refusal, not a guess, and this is now visible in the
acceptance run. The e2e's 64x88 stand-in has to be blown up nearly 8x to reach
canonical space, and a bilinear 8x upscale turns a step edge into a 16px ramp
with a comparable ridge at each shoulder — so the snapper refuses on it, out
loud, and the run asserts that. Snapping itself is asserted against a 600x825
fixture, which is the resolution
images.highactually serves. A test that asserted snapping against the small one would have been asserting that the snapper guesses. -
Preparation is off the interaction path: ~70ms for the tensor at 504x704,
once per card, behind an idle callback, and the pen is fully usable unsnapped
until it lands.
data-pen-snap-providerdistinguishes "not wired yet" from "switched off", because a test that cannot tell those apart races the callback. - The refusal is shown, quietly. One dim line in a strip on the card face that never blocks a click and is gone on the next gesture. A user who cannot tell a deliberate refusal from a snapper that failed to notice learns to distrust the whole feature.
Decided by: Chey Rasmussen, implemented by Codex
Decision: Add a library-only opt-in that supplies surface-to-camera view direction in card-local tangent space while preserving the recipe ABI vec3 foilPattern(vec2 uv, vec2 tilt). The convention is +x along increasing UV x, +y along increasing UV y, and +z along the front normal. buildFoilShader(pattern) remains byte-for-byte unchanged; {viewDirection: true} adds uViewDirection and maps x/y through a bounded perspective divide before the existing recipes see it. FoilStage opts in per card through CardSettings.viewDirection and caches legacy and opted materials separately.
Why: Tangent-space view direction is established public prior art for stylized holofoil response (Cyanilux, “Holofoil Card Shader Breakdown”), and Three's documented matrix/normal conventions support deriving it from current transforms. This is an original implementation and a better geometric input than synthetic pointer tilt when a surface explicitly requests it.
Implications: The Three adapter computes a planar-center approximation after camera and mesh transforms are current. Perspective uses center-to-camera; orthographic uses its parallel view ray. The orthonormal frame preserves transformed UV x and the inverse-transpose front normal, including reflections; under hierarchy-induced shear, Gram-Schmidt chooses the bitangent sign that agrees with transformed UV y. Singular or non-finite frames use +z. Direct uniform writers remain responsible for finite normalized input. This does not provide per-fragment perspective parallax, does not retune any recipe or default, and makes no claim of physical or foil fidelity. The shader's bounded mapping covers zero, grazing, and backfacing finite vectors. Conceptual sources: https://www.cyanilux.com/tutorials/holofoil-card-shader-breakdown/ and https://threejs.org/docs/pages/Matrix3.html.
- Pre-History — the decisions made before this repository existed, as their commit messages
- Home — the routing table for the repo/wiki split
Last updated by Codex on behalf of @cheyras — 2026-09-22