@cardanvil/frame-kit 0.6.0 → 0.7.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 (66) hide show
  1. package/README.md +75 -1
  2. package/dist/authoring/collectorInfo.d.ts +17 -0
  3. package/dist/authoring/collectorInfo.js +62 -0
  4. package/dist/authoring/index.d.ts +2 -0
  5. package/dist/authoring/index.js +2 -0
  6. package/dist/authoring/layoutDefaults.d.ts +20 -2
  7. package/dist/authoring/layoutDefaults.js +48 -13
  8. package/dist/authoring/opacityStops.d.ts +8 -0
  9. package/dist/authoring/opacityStops.js +17 -0
  10. package/dist/cli/index.d.ts +1 -0
  11. package/dist/cli/index.js +1 -0
  12. package/dist/cli/loadFrame.js +1 -1
  13. package/dist/cli/main.js +72 -0
  14. package/dist/cli/studio/boxSets.js +11 -8
  15. package/dist/cli/studio/serve.js +4 -1
  16. package/dist/cli/trim.d.ts +62 -0
  17. package/dist/cli/trim.js +251 -0
  18. package/dist/layout/border.d.ts +27 -0
  19. package/dist/layout/border.js +31 -0
  20. package/dist/layout/boxOverrides.d.ts +28 -0
  21. package/dist/layout/boxOverrides.js +95 -0
  22. package/dist/layout/color.d.ts +23 -0
  23. package/dist/layout/color.js +53 -0
  24. package/dist/layout/cutouts.d.ts +28 -0
  25. package/dist/layout/cutouts.js +29 -0
  26. package/dist/layout/decorations.d.ts +1 -6
  27. package/dist/layout/decorations.js +7 -11
  28. package/dist/layout/frameLayers.d.ts +29 -2
  29. package/dist/layout/frameLayers.js +145 -3
  30. package/dist/layout/index.d.ts +17 -6
  31. package/dist/layout/index.js +17 -6
  32. package/dist/layout/paint.d.ts +36 -0
  33. package/dist/layout/paint.js +148 -0
  34. package/dist/layout/powerToughnessRow.d.ts +26 -0
  35. package/dist/layout/powerToughnessRow.js +13 -0
  36. package/dist/layout/resolveFrameAsset.d.ts +6 -0
  37. package/dist/layout/resolveFrameAsset.js +8 -0
  38. package/dist/layout/rulesSlide.d.ts +13 -0
  39. package/dist/layout/rulesSlide.js +33 -0
  40. package/dist/layout/settings.d.ts +26 -0
  41. package/dist/layout/settings.js +62 -0
  42. package/dist/layout/textStyle.d.ts +29 -0
  43. package/dist/layout/textStyle.js +28 -0
  44. package/dist/layout/turn.d.ts +54 -0
  45. package/dist/layout/turn.js +107 -0
  46. package/dist/manifest/contract.d.ts +6074 -9392
  47. package/dist/manifest/contract.js +46 -1
  48. package/dist/manifest/testFrame.js +9 -0
  49. package/dist/schema/collectorInfo.d.ts +95 -0
  50. package/dist/schema/collectorInfo.js +80 -0
  51. package/dist/schema/condition.d.ts +67 -0
  52. package/dist/schema/condition.js +47 -0
  53. package/dist/schema/frame.d.ts +17291 -25435
  54. package/dist/schema/frame.js +402 -14
  55. package/dist/schema/frameAssets.d.ts +53 -33
  56. package/dist/schema/frameAssets.js +77 -5
  57. package/dist/schema/index.d.ts +3 -0
  58. package/dist/schema/index.js +3 -0
  59. package/dist/schema/layout.d.ts +29 -4
  60. package/dist/schema/layout.js +27 -2
  61. package/dist/schema/paint.d.ts +202 -0
  62. package/dist/schema/paint.js +109 -0
  63. package/dist/studio/assets/index-BnhNwm_r.js +74 -0
  64. package/dist/studio/index.html +1 -1
  65. package/package.json +5 -3
  66. package/dist/studio/assets/index-CNzyV22G.js +0 -76
@@ -18,7 +18,44 @@ export const LAYERS = {
18
18
  };
19
19
  /** WUBRG in canonical order, which every multi-colour string is sorted into. */
20
20
  export const COLOR_ORDER = ["W", "U", "B", "R", "G"];
21
- const COLOR_LETTERS = new Set(COLOR_ORDER);
21
+ /** The five colour letters, for checking a string is made of them. */
22
+ export const COLOR_LETTERS = new Set(COLOR_ORDER);
23
+ /**
24
+ * Two- and three-colour combinations in the order cards print them: allied
25
+ * pairs then enemy pairs, shards then wedges. Mirrors Card Anvil's
26
+ * `frameLogic.ts` lookup.
27
+ */
28
+ const PRINTED_ORDERS = [
29
+ "WU",
30
+ "UB",
31
+ "BR",
32
+ "RG",
33
+ "GW",
34
+ "WB",
35
+ "BG",
36
+ "GU",
37
+ "UR",
38
+ "RW",
39
+ "GWU",
40
+ "WUB",
41
+ "UBR",
42
+ "BRG",
43
+ "RGW",
44
+ "WBG",
45
+ "URW",
46
+ "BGU",
47
+ "RWB",
48
+ "GUR",
49
+ ];
50
+ const sortedKey = (colors) => Array.from(colors).sort().join("");
51
+ const PRINTED_ORDER_BY_KEY = new Map(PRINTED_ORDERS.map((combo) => [sortedKey(combo), combo]));
52
+ /**
53
+ * Two or three colours in the order cards print them (`UW` → `WU`, `UGW` →
54
+ * `GWU`); anything else as it came.
55
+ */
56
+ export function printedColorOrder(colors) {
57
+ return PRINTED_ORDER_BY_KEY.get(sortedKey(colors)) ?? colors;
58
+ }
22
59
  /** Whether two refs point at the same family and colour. */
23
60
  function sameRef(a, b) {
24
61
  return a.family === b.family && a.color === b.color;
@@ -186,6 +223,26 @@ export function selectFrameLayers(details, options = {}) {
186
223
  !hasPinlineMask &&
187
224
  details.background === LAYERS.ARTIFACT &&
188
225
  details.identity.length > 0;
226
+ // Without a pinlines mask the split pinline overlays can never draw, so a
227
+ // two-colour card would fall to the gold frame; halve the base instead.
228
+ if (family !== "land" &&
229
+ !hasPinlineMask &&
230
+ !details.isColorless &&
231
+ (details.background === LAYERS.GOLD || usesArtifactFallback)) {
232
+ const split = splitTwoColor(details.identity);
233
+ if (split) {
234
+ return {
235
+ baseFrame: makeFamilyRef(split[0], family),
236
+ overlays: [
237
+ {
238
+ frame: makeFamilyRef(split[1], family),
239
+ mask: "rightHalf",
240
+ preserveAlpha: true,
241
+ },
242
+ ],
243
+ };
244
+ }
245
+ }
189
246
  const baseColor = colorToken(family === "land" || usesArtifactFallback
190
247
  ? details.identity
191
248
  : details.background);
@@ -196,10 +253,95 @@ export function selectFrameLayers(details, options = {}) {
196
253
  ...(overlays.length > 0 ? { overlays } : {}),
197
254
  };
198
255
  }
199
- /** WUBRG order, which every colour combination is normalised into. */
256
+ /**
257
+ * Overlays colouring one page of a storybook: a colour on its own, two with
258
+ * the second faded in through the page's `Right` mask, gold at three or more.
259
+ * A one-colour page that matches the base frame needs nothing — unless the
260
+ * base was halved, which runs the second colour into the pages too.
261
+ */
262
+ function pageOverlays(colors, mask, rightMask, family, baseRef, halved) {
263
+ const split = splitTwoColor(colors);
264
+ if (split) {
265
+ return [
266
+ { frame: makeFamilyRef(split[0], family), mask, preserveAlpha: true },
267
+ {
268
+ frame: makeFamilyRef(split[1], family),
269
+ mask: rightMask,
270
+ preserveAlpha: true,
271
+ },
272
+ ];
273
+ }
274
+ const page = makeFamilyRef(colorToken(colors), family);
275
+ return sameRef(page, baseRef) && !halved
276
+ ? []
277
+ : [{ frame: page, mask, preserveAlpha: true }];
278
+ }
279
+ /**
280
+ * The art to composite for a storybook Adventure, whose text box is an open
281
+ * book: the card's rules on the right page, its adventure on the left.
282
+ *
283
+ * Everything outside the book is selected as for any card, as though the
284
+ * layout had pinlines, with one exception: the book has no pinline art, so a
285
+ * two-colour gold card halves its base frame between its colours, which
286
+ * splits the frame's own pinlines, and lays gold over the title and type bars
287
+ * as the regular frame has them, and over the book's page edges (`wings`).
288
+ * Each page is then coloured on its own: the right by the card's pinline
289
+ * colours, the left by `adventureColors`, the adventure's colours in the
290
+ * order cards print them (see `printedColorOrder`). An adventure with no
291
+ * colours takes the card's.
292
+ */
293
+ export function selectStorybookFrameLayers(details, adventureColors, options = {}) {
294
+ const family = options.family ?? "normal";
295
+ const goldSplit = details.background === LAYERS.GOLD &&
296
+ !details.isHybrid &&
297
+ !details.isColorless
298
+ ? splitTwoColor(details.pinlines)
299
+ : null;
300
+ const card = goldSplit
301
+ ? {
302
+ baseFrame: makeFamilyRef(goldSplit[0], family),
303
+ overlays: [
304
+ {
305
+ frame: makeFamilyRef(goldSplit[1], family),
306
+ mask: "rightHalf",
307
+ preserveAlpha: true,
308
+ },
309
+ {
310
+ frame: makeFamilyRef(colorToken(details.twins), family),
311
+ mask: "titleAndType",
312
+ preserveAlpha: true,
313
+ },
314
+ {
315
+ frame: makeFamilyRef("m", family),
316
+ mask: "wings",
317
+ preserveAlpha: true,
318
+ },
319
+ ],
320
+ }
321
+ : selectFrameLayers(details, { ...options, hasPinlineMask: true });
322
+ // The book replaces the text box and has no pinlines, so the overlays meant
323
+ // for those are dropped and the pages coloured instead.
324
+ const kept = (card.overlays ?? []).filter((overlay) => overlay.mask !== "rules" && overlay.mask !== "pinlines");
325
+ const halved = kept.some((overlay) => overlay.mask === "rightHalf");
326
+ const overlays = [
327
+ ...kept,
328
+ ...pageOverlays(details.pinlines, "rules", "rulesRight", family, card.baseFrame, halved),
329
+ ...pageOverlays(adventureColors || details.pinlines, "adventureRules", "adventureRulesRight", family, card.baseFrame, halved),
330
+ ];
331
+ return {
332
+ baseFrame: card.baseFrame,
333
+ ...(overlays.length > 0 ? { overlays } : {}),
334
+ };
335
+ }
336
+ /**
337
+ * A card's colours as one string, in the order cards print them: `GW`, not
338
+ * `WG`. Card Anvil orders a card's identity the same way, and a two-colour
339
+ * split reads its left half from the first letter, so a plain WUBRG sort
340
+ * would swap the halves of GW, GU and RW cards. Four colours stay WUBRG.
341
+ */
200
342
  export function orderColors(colors) {
201
343
  const wanted = new Set(colors.map((color) => color.toUpperCase()));
202
- return COLOR_ORDER.filter((letter) => wanted.has(letter)).join("");
344
+ return printedColorOrder(COLOR_ORDER.filter((letter) => wanted.has(letter)).join(""));
203
345
  }
204
346
  /**
205
347
  * The frame details a card shape implies.
@@ -1,16 +1,27 @@
1
1
  /**
2
- * How a frame's art is chosen and placed.
2
+ * How a frame is drawn for one card: which art a card selects and where each
3
+ * piece goes, which cutouts and border recolor apply, which template settings
4
+ * are in effect, and how text boxes resolve and are styled.
3
5
  *
4
6
  * 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
7
+ * `FrameDetails` — and this owns everything downstream of it. It all used to
8
+ * live in the app; it moved here so the studio and the renderer cannot drift
8
9
  * on it.
9
10
  *
10
11
  * A separate entry point from the main one: nothing on the packaging path
11
12
  * needs it, and frames themselves never import it.
12
13
  */
13
14
  export { CROWN_BLACK_BAR, DEFAULT_PT_IMAGE, NYX_INSERT_FALLBACK_X, type Placed, type Placement, type PlacementConfig, placeAsset, placementOrigin, } from "./assetPlacement.js";
15
+ export { type BorderStateInput, borderStateFor, ringMaskFor, } from "./border.js";
16
+ export { type OverrideState, overrideMatches, resolveBoxOverrides, resolveSecondFaceBoxOverrides, } from "./boxOverrides.js";
17
+ export { type RgbColor, getReadableTextColor, parseNormalizedCssColor, relativeLuminance, } from "./color.js";
18
+ export { type FrameCutoutInput, frameCutoutMasks } from "./cutouts.js";
14
19
  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";
20
+ export { COLOR_ORDER, type CardShape, type FrameDetails, type FrameFamily, type FrameLayers, type FrameOverlay, LAYERS, type OverlayMaskName, type SelectOptions, colorToken, familyFor, frameDetailsFor, frameFamily, makeFamilyRef, orderColors, printedColorOrder, selectFrameLayers, selectStorybookFrameLayers, splitTwoColor, } from "./frameLayers.js";
21
+ export { type PaintStop, cardColorParts, gradientOffsetY, gradientStops, paintedFor, tintColor, } from "./paint.js";
22
+ export { type ResolvedBaseFrame, hasFrameArt, resolveBaseFrame, resolveFrameAsset, } from "./resolveFrameAsset.js";
23
+ export { type PowerToughnessRowPlacement, type PowerToughnessRowWidths, powerToughnessRow, } from "./powerToughnessRow.js";
24
+ export { rulesSlideOffset, slideBoxes } from "./rulesSlide.js";
25
+ export { type SettingValue, type TemplateSettingValues, layoutTemplateSettings, mergeTemplateSettings, templateSettingDefaults, } from "./settings.js";
26
+ export { TEXT_SHADOW_COLOR, type TextOutline, type TextShadow, textOutlineFor, textShadowFor, } from "./textStyle.js";
27
+ export { type Matrix, type Point, type Size, applyMatrix, sheetVectorInTurned, turnMatrix, turnPlacement, turnedBoundsOnSheet, turnedSheetSize, turnedVectorOnSheet, } from "./turn.js";
@@ -1,16 +1,27 @@
1
1
  /**
2
- * How a frame's art is chosen and placed.
2
+ * How a frame is drawn for one card: which art a card selects and where each
3
+ * piece goes, which cutouts and border recolor apply, which template settings
4
+ * are in effect, and how text boxes resolve and are styled.
3
5
  *
4
6
  * 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
7
+ * `FrameDetails` — and this owns everything downstream of it. It all used to
8
+ * live in the app; it moved here so the studio and the renderer cannot drift
8
9
  * on it.
9
10
  *
10
11
  * A separate entry point from the main one: nothing on the packaging path
11
12
  * needs it, and frames themselves never import it.
12
13
  */
13
14
  export { CROWN_BLACK_BAR, DEFAULT_PT_IMAGE, NYX_INSERT_FALLBACK_X, placeAsset, placementOrigin, } from "./assetPlacement.js";
15
+ export { borderStateFor, ringMaskFor, } from "./border.js";
16
+ export { overrideMatches, resolveBoxOverrides, resolveSecondFaceBoxOverrides, } from "./boxOverrides.js";
17
+ export { getReadableTextColor, parseNormalizedCssColor, relativeLuminance, } from "./color.js";
18
+ export { frameCutoutMasks } from "./cutouts.js";
14
19
  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";
20
+ export { COLOR_ORDER, LAYERS, colorToken, familyFor, frameDetailsFor, frameFamily, makeFamilyRef, orderColors, printedColorOrder, selectFrameLayers, selectStorybookFrameLayers, splitTwoColor, } from "./frameLayers.js";
21
+ export { cardColorParts, gradientOffsetY, gradientStops, paintedFor, tintColor, } from "./paint.js";
22
+ export { hasFrameArt, resolveBaseFrame, resolveFrameAsset, } from "./resolveFrameAsset.js";
23
+ export { powerToughnessRow, } from "./powerToughnessRow.js";
24
+ export { rulesSlideOffset, slideBoxes } from "./rulesSlide.js";
25
+ export { layoutTemplateSettings, mergeTemplateSettings, templateSettingDefaults, } from "./settings.js";
26
+ export { TEXT_SHADOW_COLOR, textOutlineFor, textShadowFor, } from "./textStyle.js";
27
+ export { applyMatrix, sheetVectorInTurned, turnMatrix, turnPlacement, turnedBoundsOnSheet, turnedSheetSize, turnedVectorOnSheet, } from "./turn.js";
@@ -0,0 +1,36 @@
1
+ import type { CardBoxes, LayoutConfig } from "../schema/frame.js";
2
+ import type { FrameColor } from "../schema/frameAssets.js";
3
+ import type { Gradient, TintedImage } from "../schema/paint.js";
4
+ import { type OverrideState } from "./boxOverrides.js";
5
+ import { type FrameDetails } from "./frameLayers.js";
6
+ /**
7
+ * The colors a card paints side by side, at most `maxParts` of them; see
8
+ * `CardColorsSchema`. Read from the pinlines, as Proxyshop's frames do, with
9
+ * one difference: a three-color card's pinlines are gold, so its three come
10
+ * from its identity.
11
+ */
12
+ export declare function cardColorParts(details: FrameDetails, maxParts?: number): FrameColor[];
13
+ /** A gradient stop as a canvas takes it: a position, and a CSS color. */
14
+ export interface PaintStop {
15
+ readonly at: number;
16
+ readonly color: string;
17
+ }
18
+ /**
19
+ * A gradient's stops for one card, color and opacity merged into one list a
20
+ * canvas can draw — a stop wherever either changes — or `undefined` when it
21
+ * paints nothing (its palette has no color for the card).
22
+ */
23
+ export declare function gradientStops(gradient: Gradient, details: FrameDetails): PaintStop[] | undefined;
24
+ /**
25
+ * How far down from its own `y` a gradient is painted for a card drawn with
26
+ * `boxes`, which a `rulesSlide` has moved `slideOffset`: its anchor box's top
27
+ * if it has one, otherwise `slideOffset` if it `slides`, else 0.
28
+ */
29
+ export declare function gradientOffsetY(gradient: Gradient, boxes: CardBoxes, slideOffset: number): number;
30
+ /**
31
+ * A layout as one render paints it: only the gradients and tinted images
32
+ * whose `when` holds for `state`. Everything else passes through untouched.
33
+ */
34
+ export declare function paintedFor<Config extends Pick<LayoutConfig, "gradients" | "tintedImages">>(config: Config, state: OverrideState): Config;
35
+ /** The color a tinted image is painted for one card, if its palette has one. */
36
+ export declare function tintColor(image: TintedImage, details: FrameDetails): string | undefined;
@@ -0,0 +1,148 @@
1
+ import { overrideMatches } from "./boxOverrides.js";
2
+ import { parseNormalizedCssColor } from "./color.js";
3
+ import { COLOR_LETTERS, LAYERS, colorToken, printedColorOrder, } from "./frameLayers.js";
4
+ /** Whether `value` is two or three distinct mana colors, e.g. `UW`. */
5
+ function isColorCombo(value) {
6
+ return ((value.length === 2 || value.length === 3) &&
7
+ new Set(value).size === value.length &&
8
+ Array.from(value).every((letter) => COLOR_LETTERS.has(letter)));
9
+ }
10
+ /**
11
+ * The colors a card paints side by side, at most `maxParts` of them; see
12
+ * `CardColorsSchema`. Read from the pinlines, as Proxyshop's frames do, with
13
+ * one difference: a three-color card's pinlines are gold, so its three come
14
+ * from its identity.
15
+ */
16
+ export function cardColorParts(details, maxParts = 1) {
17
+ const combo = isColorCombo(details.pinlines)
18
+ ? details.pinlines
19
+ : details.pinlines === LAYERS.GOLD &&
20
+ details.identity.length === 3 &&
21
+ isColorCombo(details.identity)
22
+ ? details.identity
23
+ : undefined;
24
+ if (combo === undefined) {
25
+ return [colorToken(details.pinlines)];
26
+ }
27
+ if (combo.length > maxParts) {
28
+ return ["m"];
29
+ }
30
+ return Array.from(printedColorOrder(combo), (letter) => colorToken(letter));
31
+ }
32
+ /** The palette's color for each of the card's parts, or none if any is missing. */
33
+ function paletteColors(colors, details) {
34
+ const hexes = cardColorParts(details, colors.maxParts).map((part) => colors.palette[part]);
35
+ return hexes.every((hex) => hex !== undefined) ? hexes : [];
36
+ }
37
+ function rgbOf(hex) {
38
+ return parseNormalizedCssColor(hex) ?? { r: 0, g: 0, b: 0 };
39
+ }
40
+ /** Linear interpolation over stops sorted by `at`, held flat past either end. */
41
+ function interpolate(stops, at, mix) {
42
+ const first = stops[0];
43
+ const last = stops[stops.length - 1];
44
+ if (!first || !last) {
45
+ throw new Error("interpolate: no stops");
46
+ }
47
+ if (at <= first.at) {
48
+ return first.value;
49
+ }
50
+ for (let i = 1; i < stops.length; i++) {
51
+ const before = stops[i - 1];
52
+ const after = stops[i];
53
+ if (before && after && at <= after.at) {
54
+ const span = after.at - before.at;
55
+ return mix(before.value, after.value, span > 0 ? (at - before.at) / span : 1);
56
+ }
57
+ }
58
+ return last.value;
59
+ }
60
+ const mixNumber = (a, b, t) => a + (b - a) * t;
61
+ const mixRgb = (a, b, t) => ({
62
+ r: mixNumber(a.r, b.r, t),
63
+ g: mixNumber(a.g, b.g, t),
64
+ b: mixNumber(a.b, b.b, t),
65
+ });
66
+ /**
67
+ * Where each color runs along a gradient split into equal parts, as color
68
+ * stops: flat across its part, blending into the next over `blend` (0–1 of
69
+ * the length) centered on where they meet.
70
+ */
71
+ function partColorStops(colors, blend) {
72
+ const half = Math.min(blend, 1 / colors.length) / 2;
73
+ return colors.flatMap((value, i) => {
74
+ const start = i / colors.length + (i > 0 ? half : 0);
75
+ const end = (i + 1) / colors.length - (i < colors.length - 1 ? half : 0);
76
+ return [
77
+ { at: start, value },
78
+ { at: end, value },
79
+ ];
80
+ });
81
+ }
82
+ const round = (value, places) => Number(value.toFixed(places));
83
+ /**
84
+ * A gradient's stops for one card, color and opacity merged into one list a
85
+ * canvas can draw — a stop wherever either changes — or `undefined` when it
86
+ * paints nothing (its palette has no color for the card).
87
+ */
88
+ export function gradientStops(gradient, details) {
89
+ const hexes = typeof gradient.color === "string"
90
+ ? [gradient.color]
91
+ : paletteColors(gradient.color, details);
92
+ if (hexes.length === 0) {
93
+ return undefined;
94
+ }
95
+ const length = gradient.direction === "horizontal" ? gradient.width : gradient.height;
96
+ const blend = typeof gradient.color === "string" || length <= 0
97
+ ? 0
98
+ : (gradient.color.blend ?? 0) / length;
99
+ const colorStops = partColorStops(hexes.map(rgbOf), blend);
100
+ const opacityStops = [...gradient.opacity]
101
+ .sort((a, b) => a.at - b.at)
102
+ .map(({ at, opacity }) => ({ at, value: opacity }));
103
+ const positions = [
104
+ ...new Set([...colorStops, ...opacityStops].map(({ at }) => at)),
105
+ ].sort((a, b) => a - b);
106
+ return positions.map((at) => {
107
+ const { r, g, b } = interpolate(colorStops, at, mixRgb);
108
+ const opacity = interpolate(opacityStops, at, mixNumber);
109
+ const channels = [Math.round(r), Math.round(g), Math.round(b)];
110
+ return {
111
+ at,
112
+ color: `rgba(${channels.join(", ")}, ${String(round(opacity, 4))})`,
113
+ };
114
+ });
115
+ }
116
+ /**
117
+ * How far down from its own `y` a gradient is painted for a card drawn with
118
+ * `boxes`, which a `rulesSlide` has moved `slideOffset`: its anchor box's top
119
+ * if it has one, otherwise `slideOffset` if it `slides`, else 0.
120
+ */
121
+ export function gradientOffsetY(gradient, boxes, slideOffset) {
122
+ if (gradient.anchor) {
123
+ return boxes[gradient.anchor].y;
124
+ }
125
+ return gradient.slides === true ? slideOffset : 0;
126
+ }
127
+ /**
128
+ * A layout as one render paints it: only the gradients and tinted images
129
+ * whose `when` holds for `state`. Everything else passes through untouched.
130
+ */
131
+ export function paintedFor(config, state) {
132
+ const holds = ({ when }) => when === undefined || overrideMatches(when, state);
133
+ return {
134
+ ...config,
135
+ ...(config.gradients && { gradients: config.gradients.filter(holds) }),
136
+ ...(config.tintedImages && {
137
+ tintedImages: config.tintedImages.filter(holds),
138
+ }),
139
+ };
140
+ }
141
+ /** The color a tinted image is painted for one card, if its palette has one. */
142
+ export function tintColor(image, details) {
143
+ if (typeof image.color === "string") {
144
+ return image.color;
145
+ }
146
+ const hexes = paletteColors(image.color, details);
147
+ return hexes[Math.min(image.color.part ?? 0, hexes.length - 1)];
148
+ }
@@ -0,0 +1,26 @@
1
+ import type { PowerToughnessRow } from "../schema/frame.js";
2
+ /** How wide each part of a `powerToughnessRow` is drawn, in sheet px. */
3
+ export interface PowerToughnessRowWidths {
4
+ power: number;
5
+ toughness: number;
6
+ /** The widest image in the power's `ptSlot`; 0, or left out, for none. */
7
+ powerSymbol?: number;
8
+ /** The widest image in the toughness's `ptSlot`; 0, or left out, for none. */
9
+ toughnessSymbol?: number;
10
+ }
11
+ /** Where a `powerToughnessRow` puts each part, in sheet x. */
12
+ export interface PowerToughnessRowPlacement {
13
+ /** The power number's left edge. */
14
+ power: number;
15
+ /** The toughness number's left edge. */
16
+ toughness: number;
17
+ /** Where the power's symbols end: their right edge. */
18
+ powerSymbol: number;
19
+ /** Where the toughness's symbols end: their right edge. */
20
+ toughnessSymbol: number;
21
+ }
22
+ /**
23
+ * Lays out a `powerToughnessRow` from its right edge: the toughness, its
24
+ * symbol, the power, then the power's symbol, each left of the one before.
25
+ */
26
+ export declare function powerToughnessRow(row: PowerToughnessRow, widths: PowerToughnessRowWidths): PowerToughnessRowPlacement;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Lays out a `powerToughnessRow` from its right edge: the toughness, its
3
+ * symbol, the power, then the power's symbol, each left of the one before.
4
+ */
5
+ export function powerToughnessRow(row, widths) {
6
+ const toughness = row.right - widths.toughness;
7
+ const toughnessSymbol = toughness - row.gap;
8
+ const power = toughnessSymbol -
9
+ (widths.toughnessSymbol ?? 0) -
10
+ row.spacing -
11
+ widths.power;
12
+ return { power, toughness, powerSymbol: power - row.gap, toughnessSymbol };
13
+ }
@@ -11,6 +11,12 @@ import type { FrameAssets, FrameRef } from "../schema/frameAssets.js";
11
11
  * Mirrors Card Anvil's `utils/resolveFrameAsset.ts`.
12
12
  */
13
13
  export declare function resolveFrameAsset(assets: FrameAssets, ref: FrameRef): string | undefined;
14
+ /**
15
+ * Whether a layout has frame art at all. One whose `base` is empty has none:
16
+ * its art runs to the edges, and whatever it puts over the art — gradients,
17
+ * tinted images, text — is all there is.
18
+ */
19
+ export declare function hasFrameArt(assets: FrameAssets): boolean;
14
20
  export interface ResolvedBaseFrame {
15
21
  ref: FrameRef;
16
22
  url: string;
@@ -26,6 +26,14 @@ export function resolveFrameAsset(assets, ref) {
26
26
  return assets.creature?.[ref.color];
27
27
  }
28
28
  }
29
+ /**
30
+ * Whether a layout has frame art at all. One whose `base` is empty has none:
31
+ * its art runs to the edges, and whatever it puts over the art — gradients,
32
+ * tinted images, text — is all there is.
33
+ */
34
+ export function hasFrameArt(assets) {
35
+ return Object.values(assets.base).length > 0;
36
+ }
29
37
  /**
30
38
  * The base frame, with the fallback chain the renderer uses: the requested
31
39
  * family and colour, then the same colour in `base`, then (`l` → `c`), then
@@ -0,0 +1,13 @@
1
+ import type { CardBoxes, RulesSlide } from "../schema/frame.js";
2
+ /**
3
+ * How far a `rulesSlide` moves the boxes for rules text `textHeight` tall in
4
+ * a `rules` box `boxHeight` tall: the room left over less `padding`, rounded
5
+ * down to whole steps, at most `maxSteps` of them.
6
+ */
7
+ export declare function rulesSlideOffset(slide: RulesSlide, boxHeight: number, textHeight: number): number;
8
+ /**
9
+ * The boxes once a `rulesSlide` has moved them down by `offset`: the listed
10
+ * boxes moved whole, and the `rules` box's top moved with them while its
11
+ * bottom stays put.
12
+ */
13
+ export declare function slideBoxes(boxes: CardBoxes, slide: RulesSlide, offset: number): CardBoxes;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * How far a `rulesSlide` moves the boxes for rules text `textHeight` tall in
3
+ * a `rules` box `boxHeight` tall: the room left over less `padding`, rounded
4
+ * down to whole steps, at most `maxSteps` of them.
5
+ */
6
+ export function rulesSlideOffset(slide, boxHeight, textHeight) {
7
+ const room = boxHeight - textHeight - slide.padding;
8
+ const steps = Math.min(slide.maxSteps, Math.floor(room / slide.step));
9
+ return steps > 0 ? steps * slide.step : 0;
10
+ }
11
+ /**
12
+ * The boxes once a `rulesSlide` has moved them down by `offset`: the listed
13
+ * boxes moved whole, and the `rules` box's top moved with them while its
14
+ * bottom stays put.
15
+ */
16
+ export function slideBoxes(boxes, slide, offset) {
17
+ if (offset === 0) {
18
+ return boxes;
19
+ }
20
+ const slid = { ...boxes };
21
+ for (const key of slide.boxes) {
22
+ const box = boxes[key];
23
+ Object.assign(slid, { [key]: { ...box, y: box.y + offset } });
24
+ }
25
+ if (boxes.rules) {
26
+ slid.rules = {
27
+ ...boxes.rules,
28
+ y: boxes.rules.y + offset,
29
+ height: boxes.rules.height - offset,
30
+ };
31
+ }
32
+ return slid;
33
+ }
@@ -0,0 +1,26 @@
1
+ import type { Frame } from "../schema/frame.js";
2
+ /** One template setting's value. */
3
+ export type SettingValue = boolean | string | number;
4
+ /**
5
+ * A frame's template settings as an app stores them: frame-level values as
6
+ * top-level keys, and each layout's own settings as a sub-object under the
7
+ * layout's name.
8
+ */
9
+ export type TemplateSettingValues = Record<string, SettingValue | Record<string, SettingValue>>;
10
+ /**
11
+ * A frame's declared defaults, in that shape. Only `config.layouts`
12
+ * contributes layout settings — an alternate layout shares its base layout's.
13
+ */
14
+ export declare function templateSettingDefaults(frame: Frame): TemplateSettingValues;
15
+ /**
16
+ * Merges a sparse override onto a base, one level deep so per-layout
17
+ * sub-objects merge by key rather than being replaced wholesale.
18
+ */
19
+ export declare function mergeTemplateSettings(base: TemplateSettingValues | undefined, override: TemplateSettingValues | undefined): TemplateSettingValues | undefined;
20
+ /**
21
+ * The settings in effect for one layout — what the renderer reads, and what a
22
+ * text box's `when.settings` matches: the frame-level values with the
23
+ * layout's own sub-object laid over them. Other layouts' sub-objects are left
24
+ * out.
25
+ */
26
+ export declare function layoutTemplateSettings(settings: TemplateSettingValues | undefined, layout: string): Record<string, SettingValue>;
@@ -0,0 +1,62 @@
1
+ function isPlainObject(value) {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
4
+ /**
5
+ * A frame's declared defaults, in that shape. Only `config.layouts`
6
+ * contributes layout settings — an alternate layout shares its base layout's.
7
+ */
8
+ export function templateSettingDefaults(frame) {
9
+ const defaults = {};
10
+ for (const [key, setting] of Object.entries(frame.templateSettings ?? {})) {
11
+ defaults[key] = setting.defaultValue;
12
+ }
13
+ for (const [layout, config] of Object.entries(frame.config.layouts)) {
14
+ const settings = config.templateSettings;
15
+ if (!settings) {
16
+ continue;
17
+ }
18
+ const layoutDefaults = {};
19
+ for (const [key, setting] of Object.entries(settings)) {
20
+ layoutDefaults[key] = setting.defaultValue;
21
+ }
22
+ defaults[layout] = layoutDefaults;
23
+ }
24
+ return defaults;
25
+ }
26
+ /**
27
+ * Merges a sparse override onto a base, one level deep so per-layout
28
+ * sub-objects merge by key rather than being replaced wholesale.
29
+ */
30
+ export function mergeTemplateSettings(base, override) {
31
+ if (!override) {
32
+ return base;
33
+ }
34
+ if (!base) {
35
+ return override;
36
+ }
37
+ const result = { ...base };
38
+ for (const [key, value] of Object.entries(override)) {
39
+ const baseValue = result[key];
40
+ result[key] =
41
+ isPlainObject(value) && isPlainObject(baseValue)
42
+ ? { ...baseValue, ...value }
43
+ : value;
44
+ }
45
+ return result;
46
+ }
47
+ /**
48
+ * The settings in effect for one layout — what the renderer reads, and what a
49
+ * text box's `when.settings` matches: the frame-level values with the
50
+ * layout's own sub-object laid over them. Other layouts' sub-objects are left
51
+ * out.
52
+ */
53
+ export function layoutTemplateSettings(settings, layout) {
54
+ const inEffect = {};
55
+ for (const [key, value] of Object.entries(settings ?? {})) {
56
+ if (!isPlainObject(value)) {
57
+ inEffect[key] = value;
58
+ }
59
+ }
60
+ const own = settings?.[layout];
61
+ return isPlainObject(own) ? { ...inEffect, ...own } : inEffect;
62
+ }
@@ -0,0 +1,29 @@
1
+ import type { TextBox } from "../schema/frame.js";
2
+ /** The color plain-text drop shadows are drawn in. */
3
+ export declare const TEXT_SHADOW_COLOR = "#17191b";
4
+ export interface TextOutline {
5
+ color: string;
6
+ width: number;
7
+ }
8
+ export interface TextShadow {
9
+ color: string;
10
+ /** In canvas pixels. */
11
+ offsetX: number;
12
+ offsetY: number;
13
+ /** In canvas pixels; 0 is a hard edge. */
14
+ blur: number;
15
+ }
16
+ type StyledBox = Pick<TextBox, "fontSize" | "outlineColor" | "outlineWidth" | "shadow" | "shadowOffsetX" | "shadowOffsetY" | "shadowBlur">;
17
+ /** A box's outline, when it sets both an `outlineColor` and an `outlineWidth`. */
18
+ export declare function textOutlineFor(box: StyledBox): TextOutline | undefined;
19
+ /**
20
+ * The drop shadow a plain-text box — title, type, P/T, nickname title,
21
+ * collector info — is drawn with: only when it asks for one with `shadow`,
22
+ * and never alongside an outline, which always wins. The offsets default to
23
+ * fractions of the box's `fontSize`, unless the box sets its own.
24
+ *
25
+ * Symbols read `shadow` differently (on unless turned off), so this is for
26
+ * plain text only.
27
+ */
28
+ export declare function textShadowFor(box: StyledBox): TextShadow | undefined;
29
+ export {};