@cardanvil/frame-kit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +45 -0
  2. package/dist/cli/main.js +16 -0
  3. package/dist/cli/sourceWatcher.d.ts +40 -0
  4. package/dist/cli/sourceWatcher.js +100 -0
  5. package/dist/cli/studio/boxSets.d.ts +33 -0
  6. package/dist/cli/studio/boxSets.js +87 -0
  7. package/dist/cli/studio/edit/applyEdits.d.ts +71 -0
  8. package/dist/cli/studio/edit/applyEdits.js +177 -0
  9. package/dist/cli/studio/edit/impact.d.ts +53 -0
  10. package/dist/cli/studio/edit/impact.js +87 -0
  11. package/dist/cli/studio/edit/resolveModule.d.ts +10 -0
  12. package/dist/cli/studio/edit/resolveModule.js +55 -0
  13. package/dist/cli/studio/edit/resolvePath.d.ts +53 -0
  14. package/dist/cli/studio/edit/resolvePath.js +606 -0
  15. package/dist/cli/studio/edit/sourceIndex.d.ts +29 -0
  16. package/dist/cli/studio/edit/sourceIndex.js +73 -0
  17. package/dist/cli/studio/edit/ts.d.ts +14 -0
  18. package/dist/cli/studio/edit/ts.js +34 -0
  19. package/dist/cli/studio/serve.d.ts +23 -0
  20. package/dist/cli/studio/serve.js +513 -0
  21. package/dist/cli/studio/session.d.ts +73 -0
  22. package/dist/cli/studio/session.js +154 -0
  23. package/dist/cli/watch.d.ts +3 -0
  24. package/dist/cli/watch.js +20 -89
  25. package/dist/layout/assetPlacement.d.ts +71 -0
  26. package/dist/layout/assetPlacement.js +104 -0
  27. package/dist/layout/decorations.d.ts +78 -0
  28. package/dist/layout/decorations.js +108 -0
  29. package/dist/layout/frameLayers.d.ts +136 -0
  30. package/dist/layout/frameLayers.js +291 -0
  31. package/dist/layout/index.d.ts +16 -0
  32. package/dist/layout/index.js +16 -0
  33. package/dist/layout/resolveFrameAsset.d.ts +31 -0
  34. package/dist/layout/resolveFrameAsset.js +58 -0
  35. package/dist/manifest/contract.d.ts +359 -1
  36. package/dist/manifest/contract.js +3 -1
  37. package/dist/schema/frame.d.ts +907 -0
  38. package/dist/schema/frame.js +16 -0
  39. package/dist/schema/frameAssets.d.ts +3 -1
  40. package/dist/schema/frameAssets.js +23 -1
  41. package/dist/studio/assets/PlantinMTProRgIt-D0FB51kb.woff2 +0 -0
  42. package/dist/studio/assets/beleren-b-B3lFvodX.ttf +0 -0
  43. package/dist/studio/assets/beleren-bsc-yvL6mU7X.ttf +0 -0
  44. package/dist/studio/assets/index-CNzyV22G.js +76 -0
  45. package/dist/studio/index.html +12 -0
  46. package/package.json +12 -3
@@ -0,0 +1,108 @@
1
+ import { splitTwoColor } from "./frameLayers.js";
2
+ /**
3
+ * The colour a card's banners take.
4
+ *
5
+ * Driven by the resolved identity rather than the background, so a crown, a
6
+ * nickname plate and a PT box on the same card always agree with each other
7
+ * and with the pinlines.
8
+ */
9
+ export function identityColors(details, facts = {}) {
10
+ const split = splitTwoColor(details.identity);
11
+ if (split) {
12
+ return split;
13
+ }
14
+ if (details.identity.length === 1) {
15
+ return details.identity.toLowerCase();
16
+ }
17
+ if (details.identity.length > 1) {
18
+ return "m";
19
+ }
20
+ // No identity: a colourless artifact takes the artifact token so its crown
21
+ // matches its artifact pinlines; anything else stays colourless.
22
+ return facts.isArtifact === true ? "a" : "c";
23
+ }
24
+ /** Legendary crown colour(s). */
25
+ export function crownColors(details, facts = {}) {
26
+ return identityColors(details, facts);
27
+ }
28
+ /**
29
+ * Nickname banner colour(s).
30
+ *
31
+ * A land with no resolved identity uses the dedicated land nickname art rather
32
+ * than the colourless one.
33
+ */
34
+ export function nicknameColors(details, facts = {}) {
35
+ if (facts.isLand === true && !details.identity) {
36
+ return "l";
37
+ }
38
+ return identityColors(details, facts);
39
+ }
40
+ /**
41
+ * PT box colour.
42
+ *
43
+ * Only a true two-colour hybrid takes the colourless box. A card built from
44
+ * monocoloured-hybrid pips in three or more colours is an ordinary gold card
45
+ * and must resolve by identity length instead.
46
+ */
47
+ export function ptBoxColor(details, facts = {}) {
48
+ if (facts.isVehicle === true) {
49
+ return "v"; // vehicles use the dedicated plate whatever their colour
50
+ }
51
+ if (details.isHybrid) {
52
+ return "c";
53
+ }
54
+ if (details.identity.length === 1) {
55
+ return details.identity.toLowerCase();
56
+ }
57
+ if (details.identity.length > 1) {
58
+ return "m";
59
+ }
60
+ return facts.isArtifact === true ? "a" : "c";
61
+ }
62
+ /** Combined crown + nickname banner; the UB variant falls back to the plain one. */
63
+ export function resolveCrownNicknameAsset(assets, color, useUBCrowns) {
64
+ return ((useUBCrowns ? assets.crown?.nicknameUb?.[color] : undefined) ??
65
+ assets.crown?.nickname?.[color]);
66
+ }
67
+ /**
68
+ * Legendary crown art.
69
+ *
70
+ * A nicknamed card prefers the combined crown+nickname asset, and falls back
71
+ * to the plain crown when a frame does not ship one.
72
+ */
73
+ export function resolveCrownAsset(assets, color, opts) {
74
+ if (opts.nickname) {
75
+ const combined = resolveCrownNicknameAsset(assets, color, opts.useUBCrowns);
76
+ if (combined !== undefined) {
77
+ return combined;
78
+ }
79
+ }
80
+ return ((opts.useUBCrowns ? assets.crown?.ub?.[color] : undefined) ??
81
+ assets.crown?.base?.[color]);
82
+ }
83
+ export function resolveNicknameAsset(assets, color) {
84
+ return assets.nickname?.[color];
85
+ }
86
+ export function resolvePtBoxAsset(assets, color) {
87
+ return assets.pt?.[color];
88
+ }
89
+ export function resolveNyxInsertAsset(assets, color, useUBCrowns = false) {
90
+ return ((useUBCrowns ? assets.crown?.nyxInsertUb?.[color] : undefined) ??
91
+ assets.crown?.nyxInsert?.[color]);
92
+ }
93
+ /**
94
+ * Which banners a card draws.
95
+ *
96
+ * Mirrors the renderer's conditions: crowns are legendary-only, the nickname
97
+ * plate needs nickname text, and the PT box needs *both* a power and a
98
+ * toughness — a card with only one is not a creature and gets no plate.
99
+ */
100
+ export function decorationsFor(card) {
101
+ const legendary = card.isLegendary === true;
102
+ return {
103
+ crown: legendary,
104
+ crownBlackBar: legendary,
105
+ nickname: (card.nickname ?? "") !== "",
106
+ ptBox: (card.power ?? "") !== "" && (card.toughness ?? "") !== "",
107
+ };
108
+ }
@@ -0,0 +1,136 @@
1
+ import type { FrameColor, FrameRef } from "../schema/frameAssets.js";
2
+ /**
3
+ * The subset of Proxyshop's `LAYERS` enum the frame logic needs: canonical
4
+ * colour letters plus the identity labels. Mirrors Card Anvil's
5
+ * `utils/frameLogic.ts`, which is where the card-parsing half still lives.
6
+ */
7
+ export declare const LAYERS: {
8
+ readonly WHITE: "W";
9
+ readonly BLUE: "U";
10
+ readonly BLACK: "B";
11
+ readonly RED: "R";
12
+ readonly GREEN: "G";
13
+ readonly ARTIFACT: "Artifact";
14
+ readonly COLORLESS: "Colorless";
15
+ readonly LAND: "Land";
16
+ readonly GOLD: "Gold";
17
+ readonly VEHICLE: "Vehicle";
18
+ readonly HYBRID: "Hybrid";
19
+ };
20
+ /** WUBRG in canonical order, which every multi-colour string is sorted into. */
21
+ export declare const COLOR_ORDER: readonly ["W", "U", "B", "R", "G"];
22
+ /**
23
+ * What the frame logic concluded about a card, before any art is chosen.
24
+ *
25
+ * This is the seam between parsing a card and picking its frame: Card Anvil
26
+ * derives it from a Scryfall card, and the studio builds it from a colour
27
+ * picker, but both feed the same selection below.
28
+ */
29
+ export interface FrameDetails {
30
+ /** Drives the base frame: a colour, `Artifact`, `Land`, `Gold`… */
31
+ background: string;
32
+ /** Colours the pinlines and text box. */
33
+ pinlines: string;
34
+ /** Colours the name and type boxes. */
35
+ twins: string;
36
+ /** The card's colour identity; what a land's base frame uses. */
37
+ identity: string;
38
+ isColorless: boolean;
39
+ isHybrid: boolean;
40
+ }
41
+ /**
42
+ * Which set of frame art a card draws from. Lands use `land`, enchantments
43
+ * `nyx` when nyx borders are on, everything else the plain colour frames.
44
+ */
45
+ export type FrameFamily = "normal" | "land" | "nyx";
46
+ /** A mask that recolours one section of the frame. */
47
+ export type OverlayMaskName = "pinlines" | "rules" | "titleAndType" | "title" | "rightHalf";
48
+ export interface FrameOverlay {
49
+ frame: FrameRef;
50
+ mask: OverlayMaskName;
51
+ /** Applied after the primary mask. */
52
+ secondaryMask?: "rightHalf";
53
+ /** Blend RGB only, keeping the existing alpha. */
54
+ preserveAlpha?: boolean;
55
+ }
56
+ export interface FrameLayers {
57
+ baseFrame: FrameRef;
58
+ overlays?: FrameOverlay[];
59
+ }
60
+ /** Maps a `LAYERS` value to a single frame colour token. */
61
+ export declare function colorToken(value: string): FrameColor;
62
+ /** Splits a two-colour combo like `WU`, or `null` if it is not one. */
63
+ export declare function splitTwoColor(value: string): [FrameColor, FrameColor] | null;
64
+ /**
65
+ * The ref for a colour within a family.
66
+ *
67
+ * Families do not all carry every colour, and the fallbacks are deliberate:
68
+ * nyx has no vehicle frame, and a land that is not one of WUBRG or gold falls
69
+ * back to the colourless land in the base family.
70
+ */
71
+ export declare function makeFamilyRef(color: FrameColor | "c2", family: FrameFamily): FrameRef;
72
+ /** The family a type line implies. */
73
+ export declare function frameFamily(typeLine: string, useNyxBorder?: boolean): FrameFamily;
74
+ export interface SelectOptions {
75
+ readonly family?: FrameFamily;
76
+ /** Mask for the colourless wash over a two-colour hybrid. */
77
+ readonly hybridTitleMask?: "title" | "titleAndType";
78
+ /**
79
+ * Whether the layout ships a `pinlines` mask. A coloured artifact normally
80
+ * keeps the Artifact base and carries its colour in the overlays; a layout
81
+ * with no pinlines mask can never draw those, so it falls back to a
82
+ * colour-based base rather than losing the colour entirely.
83
+ */
84
+ readonly hasPinlineMask?: boolean;
85
+ }
86
+ /**
87
+ * The art to composite for a set of frame details.
88
+ *
89
+ * The selection half of Card Anvil's `getFrameLayers`, with the card parsing
90
+ * lifted out: mono and three-plus-colour cards are a single base frame, gold
91
+ * two-colour cards get split pinlines, and two-colour hybrids render as two
92
+ * halves under a colourless wash.
93
+ */
94
+ export declare function selectFrameLayers(details: FrameDetails, options?: SelectOptions): FrameLayers;
95
+ /**
96
+ * What a card is, reduced to the switches that actually change its frame.
97
+ *
98
+ * Card Anvil derives these from a Scryfall card; the studio has no card, so it
99
+ * offers them as controls. Everything here maps onto a real Scryfall
100
+ * distinction — `devoid` is the Battle for Zendikar keyword, `vehicle` and
101
+ * `artifact` are type-line terms, `land` and `enchantment` pick the frame
102
+ * family.
103
+ */
104
+ export interface CardShape {
105
+ /** Colour identity, in any order; sorted into WUBRG here. */
106
+ readonly colors?: readonly FrameColor[];
107
+ readonly isArtifact?: boolean;
108
+ readonly isVehicle?: boolean;
109
+ readonly isLand?: boolean;
110
+ readonly isEnchantment?: boolean;
111
+ /** Devoid: coloured by identity, but framed colourless. */
112
+ readonly isDevoid?: boolean;
113
+ /** A card with no colours at all — Eldrazi, most artifacts' pinlines. */
114
+ readonly isColorless?: boolean;
115
+ /** Two colours paid with hybrid mana, which splits the frame down the middle. */
116
+ readonly isHybrid?: boolean;
117
+ /** Legendary cards get the crown. */
118
+ readonly isLegendary?: boolean;
119
+ }
120
+ /** WUBRG order, which every colour combination is normalised into. */
121
+ export declare function orderColors(colors: readonly FrameColor[]): string;
122
+ /**
123
+ * The frame details a card shape implies.
124
+ *
125
+ * Mirrors the branch order of Card Anvil's `getFrameDetailsNonland` so the
126
+ * studio and the app agree on the awkward cases: devoid multicolour keeps gold
127
+ * plates over a colourless body, a colourless card goes colourless throughout,
128
+ * vehicles and artifacts override the background while their colours stay in
129
+ * the pinlines, and a hybrid pair takes land-coloured twins.
130
+ *
131
+ * Lands are simplified on purpose: the app reads basic-land types and fetch
132
+ * text to infer a land's colours, which a picker states outright.
133
+ */
134
+ export declare function frameDetailsFor(shape: CardShape): FrameDetails;
135
+ /** The frame family a card shape implies, without a type line to parse. */
136
+ export declare function familyFor(shape: CardShape, useNyxBorder?: boolean): FrameFamily;
@@ -0,0 +1,291 @@
1
+ /**
2
+ * The subset of Proxyshop's `LAYERS` enum the frame logic needs: canonical
3
+ * colour letters plus the identity labels. Mirrors Card Anvil's
4
+ * `utils/frameLogic.ts`, which is where the card-parsing half still lives.
5
+ */
6
+ export const LAYERS = {
7
+ WHITE: "W",
8
+ BLUE: "U",
9
+ BLACK: "B",
10
+ RED: "R",
11
+ GREEN: "G",
12
+ ARTIFACT: "Artifact",
13
+ COLORLESS: "Colorless",
14
+ LAND: "Land",
15
+ GOLD: "Gold",
16
+ VEHICLE: "Vehicle",
17
+ HYBRID: "Hybrid",
18
+ };
19
+ /** WUBRG in canonical order, which every multi-colour string is sorted into. */
20
+ export const COLOR_ORDER = ["W", "U", "B", "R", "G"];
21
+ const COLOR_LETTERS = new Set(COLOR_ORDER);
22
+ /** Whether two refs point at the same family and colour. */
23
+ function sameRef(a, b) {
24
+ return a.family === b.family && a.color === b.color;
25
+ }
26
+ /** Maps a `LAYERS` value to a single frame colour token. */
27
+ export function colorToken(value) {
28
+ switch (value) {
29
+ case LAYERS.WHITE:
30
+ return "w";
31
+ case LAYERS.BLUE:
32
+ return "u";
33
+ case LAYERS.BLACK:
34
+ return "b";
35
+ case LAYERS.RED:
36
+ return "r";
37
+ case LAYERS.GREEN:
38
+ return "g";
39
+ case LAYERS.ARTIFACT:
40
+ return "a";
41
+ case LAYERS.COLORLESS:
42
+ return "c";
43
+ case LAYERS.LAND:
44
+ return "l";
45
+ case LAYERS.VEHICLE:
46
+ return "v";
47
+ case LAYERS.GOLD:
48
+ return "m";
49
+ case "":
50
+ return "c";
51
+ default:
52
+ // Three or more colours share the gold frame.
53
+ return "m";
54
+ }
55
+ }
56
+ /** Splits a two-colour combo like `WU`, or `null` if it is not one. */
57
+ export function splitTwoColor(value) {
58
+ const [first, second] = value;
59
+ if (value.length === 2 &&
60
+ first !== undefined &&
61
+ second !== undefined &&
62
+ COLOR_LETTERS.has(first) &&
63
+ COLOR_LETTERS.has(second)) {
64
+ return [
65
+ first.toLowerCase(),
66
+ second.toLowerCase(),
67
+ ];
68
+ }
69
+ return null;
70
+ }
71
+ /**
72
+ * The ref for a colour within a family.
73
+ *
74
+ * Families do not all carry every colour, and the fallbacks are deliberate:
75
+ * nyx has no vehicle frame, and a land that is not one of WUBRG or gold falls
76
+ * back to the colourless land in the base family.
77
+ */
78
+ export function makeFamilyRef(color, family) {
79
+ if (family === "normal") {
80
+ return { family: "base", color };
81
+ }
82
+ if (family === "nyx") {
83
+ switch (color) {
84
+ case "w":
85
+ case "u":
86
+ case "b":
87
+ case "r":
88
+ case "g":
89
+ case "m":
90
+ case "a":
91
+ case "c":
92
+ case "l":
93
+ return { family: "nyx", color };
94
+ case "v":
95
+ return { family: "nyx", color: "a" }; // there is no nyx vehicle frame
96
+ default:
97
+ return { family: "nyx", color: "c" };
98
+ }
99
+ }
100
+ switch (color) {
101
+ case "w":
102
+ case "u":
103
+ case "b":
104
+ case "r":
105
+ case "g":
106
+ case "m":
107
+ return { family: "land", color };
108
+ default:
109
+ // a / c / l / v / c2 → the colourless land, which lives in `base`.
110
+ return { family: "base", color: "l" };
111
+ }
112
+ }
113
+ /** The family a type line implies. */
114
+ export function frameFamily(typeLine, useNyxBorder = true) {
115
+ const lower = typeLine.toLowerCase();
116
+ if (lower.includes("enchantment") && useNyxBorder) {
117
+ return "nyx";
118
+ }
119
+ return lower.includes("land") ? "land" : "normal";
120
+ }
121
+ /** Section overlays that recolour pinlines, text box and twins. */
122
+ function buildSectionOverlays(details, family, baseRef) {
123
+ const overlays = [];
124
+ const pinlineSplit = splitTwoColor(details.pinlines);
125
+ if (pinlineSplit) {
126
+ // Two colours: the left colour runs full width, the right is masked to
127
+ // its half, for both the pinlines and the text box.
128
+ const first = makeFamilyRef(pinlineSplit[0], family);
129
+ const second = makeFamilyRef(pinlineSplit[1], family);
130
+ overlays.push({ frame: first, mask: "pinlines", preserveAlpha: true }, {
131
+ frame: second,
132
+ mask: "pinlines",
133
+ secondaryMask: "rightHalf",
134
+ preserveAlpha: true,
135
+ }, { frame: first, mask: "rules", preserveAlpha: true }, {
136
+ frame: second,
137
+ mask: "rules",
138
+ secondaryMask: "rightHalf",
139
+ preserveAlpha: true,
140
+ });
141
+ }
142
+ else {
143
+ const pinlines = makeFamilyRef(colorToken(details.pinlines), family);
144
+ if (!sameRef(pinlines, baseRef)) {
145
+ overlays.push({ frame: pinlines, mask: "pinlines", preserveAlpha: true }, { frame: pinlines, mask: "rules", preserveAlpha: true });
146
+ }
147
+ }
148
+ const twins = makeFamilyRef(colorToken(details.twins), family);
149
+ if (!sameRef(twins, baseRef)) {
150
+ overlays.push({ frame: twins, mask: "titleAndType", preserveAlpha: true });
151
+ }
152
+ // The border follows the background, which the base frame already supplies.
153
+ return overlays;
154
+ }
155
+ /**
156
+ * The art to composite for a set of frame details.
157
+ *
158
+ * The selection half of Card Anvil's `getFrameLayers`, with the card parsing
159
+ * lifted out: mono and three-plus-colour cards are a single base frame, gold
160
+ * two-colour cards get split pinlines, and two-colour hybrids render as two
161
+ * halves under a colourless wash.
162
+ */
163
+ export function selectFrameLayers(details, options = {}) {
164
+ const { family = "normal", hybridTitleMask = "titleAndType", hasPinlineMask = true, } = options;
165
+ if (details.isHybrid) {
166
+ const split = splitTwoColor(details.pinlines);
167
+ if (split) {
168
+ return {
169
+ baseFrame: makeFamilyRef(split[0], family),
170
+ overlays: [
171
+ {
172
+ frame: makeFamilyRef(split[1], family),
173
+ mask: "rightHalf",
174
+ preserveAlpha: true,
175
+ },
176
+ {
177
+ frame: makeFamilyRef("c2", family),
178
+ mask: hybridTitleMask,
179
+ preserveAlpha: true,
180
+ },
181
+ ],
182
+ };
183
+ }
184
+ }
185
+ const usesArtifactFallback = family !== "land" &&
186
+ !hasPinlineMask &&
187
+ details.background === LAYERS.ARTIFACT &&
188
+ details.identity.length > 0;
189
+ const baseColor = colorToken(family === "land" || usesArtifactFallback
190
+ ? details.identity
191
+ : details.background);
192
+ const baseRef = makeFamilyRef(baseColor, family);
193
+ const overlays = buildSectionOverlays(details, family, baseRef);
194
+ return {
195
+ baseFrame: baseRef,
196
+ ...(overlays.length > 0 ? { overlays } : {}),
197
+ };
198
+ }
199
+ /** WUBRG order, which every colour combination is normalised into. */
200
+ export function orderColors(colors) {
201
+ const wanted = new Set(colors.map((color) => color.toUpperCase()));
202
+ return COLOR_ORDER.filter((letter) => wanted.has(letter)).join("");
203
+ }
204
+ /**
205
+ * The frame details a card shape implies.
206
+ *
207
+ * Mirrors the branch order of Card Anvil's `getFrameDetailsNonland` so the
208
+ * studio and the app agree on the awkward cases: devoid multicolour keeps gold
209
+ * plates over a colourless body, a colourless card goes colourless throughout,
210
+ * vehicles and artifacts override the background while their colours stay in
211
+ * the pinlines, and a hybrid pair takes land-coloured twins.
212
+ *
213
+ * Lands are simplified on purpose: the app reads basic-land types and fetch
214
+ * text to infer a land's colours, which a picker states outright.
215
+ */
216
+ export function frameDetailsFor(shape) {
217
+ const identity = orderColors(shape.colors ?? []);
218
+ const details = {
219
+ background: identity,
220
+ pinlines: identity,
221
+ twins: identity,
222
+ identity,
223
+ isColorless: false,
224
+ isHybrid: false,
225
+ };
226
+ if (shape.isLand === true) {
227
+ // `selectFrameLayers` takes a land's base colour from `identity`; the
228
+ // background is always Land.
229
+ details.background = LAYERS.LAND;
230
+ if (identity.length === 0) {
231
+ details.pinlines = LAYERS.LAND;
232
+ details.twins = LAYERS.LAND;
233
+ }
234
+ else if (identity.length > 2) {
235
+ details.pinlines = LAYERS.GOLD;
236
+ details.twins = LAYERS.GOLD;
237
+ }
238
+ return details;
239
+ }
240
+ const devoid = shape.isDevoid === true && identity.length > 0;
241
+ const colorless = devoid ||
242
+ shape.isColorless === true ||
243
+ (identity.length === 0 && shape.isArtifact !== true);
244
+ if (colorless) {
245
+ if (devoid && identity.length > 1) {
246
+ details.twins = LAYERS.GOLD;
247
+ details.background = LAYERS.GOLD;
248
+ }
249
+ else if (!devoid) {
250
+ details.twins = LAYERS.COLORLESS;
251
+ details.background = LAYERS.COLORLESS;
252
+ details.pinlines = LAYERS.COLORLESS;
253
+ }
254
+ details.isColorless = true;
255
+ return details;
256
+ }
257
+ const hybrid = shape.isHybrid === true && identity.length === 2;
258
+ details.isHybrid = hybrid;
259
+ if (shape.isVehicle === true) {
260
+ details.background = LAYERS.VEHICLE;
261
+ }
262
+ else if (shape.isArtifact === true) {
263
+ details.background = LAYERS.ARTIFACT;
264
+ }
265
+ else if (identity.length >= 2 && !hybrid) {
266
+ details.background = LAYERS.GOLD;
267
+ }
268
+ if (identity.length === 0) {
269
+ details.pinlines = LAYERS.ARTIFACT;
270
+ }
271
+ else if (identity.length > 2) {
272
+ details.pinlines = LAYERS.GOLD;
273
+ }
274
+ if (identity.length === 0) {
275
+ details.twins = LAYERS.ARTIFACT;
276
+ }
277
+ else if (hybrid) {
278
+ details.twins = LAYERS.LAND;
279
+ }
280
+ else if (identity.length >= 2) {
281
+ details.twins = LAYERS.GOLD;
282
+ }
283
+ return details;
284
+ }
285
+ /** The frame family a card shape implies, without a type line to parse. */
286
+ export function familyFor(shape, useNyxBorder = true) {
287
+ if (shape.isEnchantment === true && useNyxBorder) {
288
+ return "nyx";
289
+ }
290
+ return shape.isLand === true ? "land" : "normal";
291
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * How a frame's art is chosen and placed.
3
+ *
4
+ * Card Anvil owns the card parsing — reading a Scryfall card into
5
+ * `FrameDetails` — and this owns everything downstream of it: which art a set
6
+ * of details selects, and where each piece is drawn. Both halves used to live
7
+ * in the app; this one moved here so the studio and the renderer cannot drift
8
+ * on it.
9
+ *
10
+ * A separate entry point from the main one: nothing on the packaging path
11
+ * needs it, and frames themselves never import it.
12
+ */
13
+ export { CROWN_BLACK_BAR, DEFAULT_PT_IMAGE, NYX_INSERT_FALLBACK_X, type Placed, type Placement, type PlacementConfig, placeAsset, placementOrigin, } from "./assetPlacement.js";
14
+ export { type BannerColors, type DecorationCard, type DecorationFacts, type DecorationState, crownColors, decorationsFor, identityColors, nicknameColors, ptBoxColor, resolveCrownAsset, resolveCrownNicknameAsset, resolveNicknameAsset, resolveNyxInsertAsset, resolvePtBoxAsset, } from "./decorations.js";
15
+ export { COLOR_ORDER, type CardShape, type FrameDetails, type FrameFamily, type FrameLayers, type FrameOverlay, LAYERS, type OverlayMaskName, type SelectOptions, colorToken, familyFor, frameDetailsFor, frameFamily, makeFamilyRef, orderColors, selectFrameLayers, splitTwoColor, } from "./frameLayers.js";
16
+ export { type ResolvedBaseFrame, resolveBaseFrame, resolveFrameAsset, } from "./resolveFrameAsset.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * How a frame's art is chosen and placed.
3
+ *
4
+ * Card Anvil owns the card parsing — reading a Scryfall card into
5
+ * `FrameDetails` — and this owns everything downstream of it: which art a set
6
+ * of details selects, and where each piece is drawn. Both halves used to live
7
+ * in the app; this one moved here so the studio and the renderer cannot drift
8
+ * on it.
9
+ *
10
+ * A separate entry point from the main one: nothing on the packaging path
11
+ * needs it, and frames themselves never import it.
12
+ */
13
+ export { CROWN_BLACK_BAR, DEFAULT_PT_IMAGE, NYX_INSERT_FALLBACK_X, placeAsset, placementOrigin, } from "./assetPlacement.js";
14
+ export { crownColors, decorationsFor, identityColors, nicknameColors, ptBoxColor, resolveCrownAsset, resolveCrownNicknameAsset, resolveNicknameAsset, resolveNyxInsertAsset, resolvePtBoxAsset, } from "./decorations.js";
15
+ export { COLOR_ORDER, LAYERS, colorToken, familyFor, frameDetailsFor, frameFamily, makeFamilyRef, orderColors, selectFrameLayers, splitTwoColor, } from "./frameLayers.js";
16
+ export { resolveBaseFrame, resolveFrameAsset, } from "./resolveFrameAsset.js";
@@ -0,0 +1,31 @@
1
+ import type { FrameAssets, FrameRef } from "../schema/frameAssets.js";
2
+ /**
3
+ * Exact per-family lookup. The only alias is base `c2` → base `c`, for a frame
4
+ * with no distinct hybrid wash.
5
+ *
6
+ * Deliberately no cross-family fallback: a missing `land_*` or `nyx_*` variant
7
+ * has to resolve to `undefined` so the overlay that needed it is dropped
8
+ * rather than drawn in the wrong family. Falling back on the *base* frame is a
9
+ * separate, explicit decision — see {@link resolveBaseFrame}.
10
+ *
11
+ * Mirrors Card Anvil's `utils/resolveFrameAsset.ts`.
12
+ */
13
+ export declare function resolveFrameAsset(assets: FrameAssets, ref: FrameRef): string | undefined;
14
+ export interface ResolvedBaseFrame {
15
+ ref: FrameRef;
16
+ url: string;
17
+ /**
18
+ * True when the requested ref was missing and another colour stood in.
19
+ * Callers drop every overlay in that case — the overlays were composed for
20
+ * the frame that is not there.
21
+ */
22
+ usedFallback: boolean;
23
+ }
24
+ /**
25
+ * The base frame, with the fallback chain the renderer uses: the requested
26
+ * family and colour, then the same colour in `base`, then (`l` → `c`), then
27
+ * `m`, then `w`, then whatever the frame does ship.
28
+ *
29
+ * `undefined` only when a frame ships no base art at all.
30
+ */
31
+ export declare function resolveBaseFrame(assets: FrameAssets, ref: FrameRef): ResolvedBaseFrame | undefined;
@@ -0,0 +1,58 @@
1
+ import { FRAME_COLORS } from "../schema/frameAssets.js";
2
+ /**
3
+ * Exact per-family lookup. The only alias is base `c2` → base `c`, for a frame
4
+ * with no distinct hybrid wash.
5
+ *
6
+ * Deliberately no cross-family fallback: a missing `land_*` or `nyx_*` variant
7
+ * has to resolve to `undefined` so the overlay that needed it is dropped
8
+ * rather than drawn in the wrong family. Falling back on the *base* frame is a
9
+ * separate, explicit decision — see {@link resolveBaseFrame}.
10
+ *
11
+ * Mirrors Card Anvil's `utils/resolveFrameAsset.ts`.
12
+ */
13
+ export function resolveFrameAsset(assets, ref) {
14
+ switch (ref.family) {
15
+ case "base":
16
+ return ref.color === "c2"
17
+ ? (assets.base.c2 ?? assets.base.c)
18
+ : assets.base[ref.color];
19
+ case "land":
20
+ return assets.land?.[ref.color];
21
+ case "nyx":
22
+ return assets.nyx?.[ref.color];
23
+ case "tall":
24
+ return assets.tall?.[ref.color];
25
+ case "creature":
26
+ return assets.creature?.[ref.color];
27
+ }
28
+ }
29
+ /**
30
+ * The base frame, with the fallback chain the renderer uses: the requested
31
+ * family and colour, then the same colour in `base`, then (`l` → `c`), then
32
+ * `m`, then `w`, then whatever the frame does ship.
33
+ *
34
+ * `undefined` only when a frame ships no base art at all.
35
+ */
36
+ export function resolveBaseFrame(assets, ref) {
37
+ const direct = resolveFrameAsset(assets, ref);
38
+ if (direct !== undefined) {
39
+ return { ref, url: direct, usedFallback: false };
40
+ }
41
+ const readBase = (color) => color === "c2" ? (assets.base.c2 ?? assets.base.c) : assets.base[color];
42
+ const chain = ref.color === "l" ? [ref.color, "c", "m", "w"] : [ref.color, "m", "w"];
43
+ for (const color of chain) {
44
+ const url = readBase(color);
45
+ if (url !== undefined) {
46
+ return { ref: { family: "base", color }, url, usedFallback: true };
47
+ }
48
+ }
49
+ // Last resort: the first colour the frame ships, in canonical order rather
50
+ // than whatever order the object happens to be written in.
51
+ for (const color of FRAME_COLORS) {
52
+ const url = assets.base[color];
53
+ if (url !== undefined) {
54
+ return { ref: { family: "base", color }, url, usedFallback: true };
55
+ }
56
+ }
57
+ return undefined;
58
+ }