@cardanvil/frame-kit 0.5.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 (90) hide show
  1. package/README.md +119 -0
  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 +88 -0
  14. package/dist/cli/sourceWatcher.d.ts +40 -0
  15. package/dist/cli/sourceWatcher.js +100 -0
  16. package/dist/cli/studio/boxSets.d.ts +33 -0
  17. package/dist/cli/studio/boxSets.js +90 -0
  18. package/dist/cli/studio/edit/applyEdits.d.ts +71 -0
  19. package/dist/cli/studio/edit/applyEdits.js +177 -0
  20. package/dist/cli/studio/edit/impact.d.ts +53 -0
  21. package/dist/cli/studio/edit/impact.js +87 -0
  22. package/dist/cli/studio/edit/resolveModule.d.ts +10 -0
  23. package/dist/cli/studio/edit/resolveModule.js +55 -0
  24. package/dist/cli/studio/edit/resolvePath.d.ts +53 -0
  25. package/dist/cli/studio/edit/resolvePath.js +606 -0
  26. package/dist/cli/studio/edit/sourceIndex.d.ts +29 -0
  27. package/dist/cli/studio/edit/sourceIndex.js +73 -0
  28. package/dist/cli/studio/edit/ts.d.ts +14 -0
  29. package/dist/cli/studio/edit/ts.js +34 -0
  30. package/dist/cli/studio/serve.d.ts +23 -0
  31. package/dist/cli/studio/serve.js +516 -0
  32. package/dist/cli/studio/session.d.ts +73 -0
  33. package/dist/cli/studio/session.js +154 -0
  34. package/dist/cli/trim.d.ts +62 -0
  35. package/dist/cli/trim.js +251 -0
  36. package/dist/cli/watch.d.ts +3 -0
  37. package/dist/cli/watch.js +20 -89
  38. package/dist/layout/assetPlacement.d.ts +71 -0
  39. package/dist/layout/assetPlacement.js +104 -0
  40. package/dist/layout/border.d.ts +27 -0
  41. package/dist/layout/border.js +31 -0
  42. package/dist/layout/boxOverrides.d.ts +28 -0
  43. package/dist/layout/boxOverrides.js +95 -0
  44. package/dist/layout/color.d.ts +23 -0
  45. package/dist/layout/color.js +53 -0
  46. package/dist/layout/cutouts.d.ts +28 -0
  47. package/dist/layout/cutouts.js +29 -0
  48. package/dist/layout/decorations.d.ts +73 -0
  49. package/dist/layout/decorations.js +104 -0
  50. package/dist/layout/frameLayers.d.ts +163 -0
  51. package/dist/layout/frameLayers.js +433 -0
  52. package/dist/layout/index.d.ts +27 -0
  53. package/dist/layout/index.js +27 -0
  54. package/dist/layout/paint.d.ts +36 -0
  55. package/dist/layout/paint.js +148 -0
  56. package/dist/layout/powerToughnessRow.d.ts +26 -0
  57. package/dist/layout/powerToughnessRow.js +13 -0
  58. package/dist/layout/resolveFrameAsset.d.ts +37 -0
  59. package/dist/layout/resolveFrameAsset.js +66 -0
  60. package/dist/layout/rulesSlide.d.ts +13 -0
  61. package/dist/layout/rulesSlide.js +33 -0
  62. package/dist/layout/settings.d.ts +26 -0
  63. package/dist/layout/settings.js +62 -0
  64. package/dist/layout/textStyle.d.ts +29 -0
  65. package/dist/layout/textStyle.js +28 -0
  66. package/dist/layout/turn.d.ts +54 -0
  67. package/dist/layout/turn.js +107 -0
  68. package/dist/manifest/contract.d.ts +6103 -9063
  69. package/dist/manifest/contract.js +48 -1
  70. package/dist/manifest/testFrame.js +9 -0
  71. package/dist/schema/collectorInfo.d.ts +95 -0
  72. package/dist/schema/collectorInfo.js +80 -0
  73. package/dist/schema/condition.d.ts +67 -0
  74. package/dist/schema/condition.js +47 -0
  75. package/dist/schema/frame.d.ts +17213 -24450
  76. package/dist/schema/frame.js +418 -14
  77. package/dist/schema/frameAssets.d.ts +56 -34
  78. package/dist/schema/frameAssets.js +96 -2
  79. package/dist/schema/index.d.ts +3 -0
  80. package/dist/schema/index.js +3 -0
  81. package/dist/schema/layout.d.ts +29 -4
  82. package/dist/schema/layout.js +27 -2
  83. package/dist/schema/paint.d.ts +202 -0
  84. package/dist/schema/paint.js +109 -0
  85. package/dist/studio/assets/PlantinMTProRgIt-D0FB51kb.woff2 +0 -0
  86. package/dist/studio/assets/beleren-b-B3lFvodX.ttf +0 -0
  87. package/dist/studio/assets/beleren-bsc-yvL6mU7X.ttf +0 -0
  88. package/dist/studio/assets/index-BnhNwm_r.js +74 -0
  89. package/dist/studio/index.html +12 -0
  90. package/package.json +16 -5
@@ -1,28 +1,56 @@
1
1
  import { z } from "zod";
2
2
  import { AssetUrlSchema } from "./assetUrl.js";
3
+ import { CollectorInfoLineSchema } from "./collectorInfo.js";
4
+ import { RenderConditionSchema } from "./condition.js";
3
5
  import { FrameAssetsSchema, FrameColorEnum, LayoutMasksSchema, } from "./frameAssets.js";
4
6
  import { LayoutEnum } from "./layout.js";
7
+ import { GradientSchema, HexColorSchema, SlidingBoxEnum, TintedImageSchema, } from "./paint.js";
5
8
  export const BoundsSchema = z.object({
6
9
  x: z.number(),
7
10
  y: z.number(),
8
11
  width: z.number(),
9
12
  height: z.number(),
10
13
  });
11
- export const TextBoxSchema = BoundsSchema.extend({
14
+ const TextBoxFieldsSchema = BoundsSchema.extend({
12
15
  fontSize: z.number(),
13
16
  color: z.string().optional(),
14
17
  outlineColor: z.string().optional(),
15
18
  outlineWidth: z.number().optional(),
16
19
  verticalAlign: z.enum(["top", "center"]).optional(),
17
20
  textAlign: z.enum(["left", "center", "right"]).optional(),
21
+ /** Extra spacing between characters, in px (Konva `letterSpacing`),
22
+ * expressed at the box's own `fontSize` — each run scales it by its font
23
+ * size so it reads as constant tracking. Non-zero spacing switches Konva
24
+ * to per-character drawing with kerning off; the canvas measurement path
25
+ * mirrors that so measured widths match the render. */
26
+ letterSpacing: z.number().optional(),
18
27
  /** Per-box font family override. When set, the renderer uses this instead
19
28
  * of the language-resolved default (e.g. title font). */
20
29
  fontFamily: z.string().optional(),
21
30
  opacity: z.number().min(0).max(1).optional(),
22
- /** Whether mana-cost symbols in this box get a drop-shadow. Defaults to
23
- * true when omitted (matches the original behavior). Set to false to
24
- * disable the shadow (e.g. flipside mana-cost badges on light backgrounds). */
31
+ /**
32
+ * Whether this box's content gets a drop-shadow.
33
+ *
34
+ * For symbol boxes (mana cost, and symbols embedded in rules/abilities
35
+ * text) this defaults to true when omitted, matching the original
36
+ * behavior — set to false to disable it (e.g. flipside mana-cost badges on
37
+ * light backgrounds).
38
+ *
39
+ * For plain-text boxes (title, type, P/T, etc., rendered via
40
+ * `buildOutlinedText`) there is no legacy default: omitting this leaves
41
+ * them exactly as before, and it only takes effect once set to true. It
42
+ * has no effect together with `outlineColor`/`outlineWidth` — an outline
43
+ * always wins.
44
+ */
25
45
  shadow: z.boolean().optional(),
46
+ /** Overrides the drop-shadow's default offset (in the box's own `fontSize`
47
+ * units, same convention as the default formula). Only meaningful when
48
+ * `shadow` is true. */
49
+ shadowOffsetX: z.number().optional(),
50
+ shadowOffsetY: z.number().optional(),
51
+ /** How far the drop-shadow is blurred, in px. Defaults to 0, a hard edge.
52
+ * Plain-text boxes only, and only meaningful when `shadow` is true. */
53
+ shadowBlur: z.number().nonnegative().optional(),
26
54
  /** Shaped-text mask: a full-canvas-sized PNG whose opaque pixels define the
27
55
  * region text in this box may occupy. Alpha is what matters (any pixel with
28
56
  * alpha > ~12% counts as inside), so color is irrelevant. Each wrapped line
@@ -35,17 +63,111 @@ export const TextBoxSchema = BoundsSchema.extend({
35
63
  * width. */
36
64
  mask: AssetUrlSchema.optional(),
37
65
  });
66
+ /**
67
+ * One conditional restyle of a text box: when every condition in `when` holds
68
+ * for a render, `style` is merged over the box. A condition left out matches
69
+ * anything.
70
+ */
71
+ export const TextBoxOverrideSchema = z.object({
72
+ when: RenderConditionSchema,
73
+ /**
74
+ * The fields to replace. Style only: parts of the app read box geometry
75
+ * without resolving overrides, so a conditional size or position would put
76
+ * them out of step with the render.
77
+ */
78
+ style: TextBoxFieldsSchema.pick({
79
+ color: true,
80
+ outlineColor: true,
81
+ outlineWidth: true,
82
+ shadow: true,
83
+ shadowOffsetX: true,
84
+ shadowOffsetY: true,
85
+ shadowBlur: true,
86
+ opacity: true,
87
+ }),
88
+ });
89
+ const textBoxSchema = TextBoxFieldsSchema.extend({
90
+ /**
91
+ * Conditional restyles, applied in order at render time — where two matches
92
+ * set the same field, the later one wins. This is how a box adapts to the
93
+ * border settings: the renderer never restyles text on its own. Text printed
94
+ * on the border ring wants `onBorderTextOverrides`, which
95
+ * `withCollectorInfoDefaults` gives collector info unless its bounds bring
96
+ * their own — `[]` for none.
97
+ */
98
+ overrides: z.array(TextBoxOverrideSchema).readonly().optional(),
99
+ });
100
+ export const TextBoxSchema = textBoxSchema;
101
+ export const CollectorInfoBoxSchema = TextBoxSchema.extend({
102
+ /**
103
+ * What the collector box prints, top line first. The renderer draws the
104
+ * default two-line layout (`defaultCollectorInfoLines`) when this is left
105
+ * out; `withCollectorInfoDefaults` fills that same default in. Build one from
106
+ * scratch for a frame with its own collector line, or start from
107
+ * `retroCollectorInfoLines`.
108
+ */
109
+ lines: z.array(CollectorInfoLineSchema).readonly().optional(),
110
+ });
111
+ /**
112
+ * Where the set symbol goes. The symbol is fitted into the box, right-aligned
113
+ * and vertically centered. An outline is drawn around its silhouette only when
114
+ * both `outlineColor` and `outlineWidth` are set, centered on the edge like a
115
+ * text outline, so it reaches `outlineWidth / 2` past the symbol.
116
+ */
117
+ export const SetSymbolBoxSchema = BoundsSchema.extend({
118
+ outlineColor: z.string().optional(),
119
+ outlineWidth: z.number().optional(),
120
+ });
121
+ /**
122
+ * How far a card is turned, in degrees clockwise, to read a box set. See
123
+ * `rotation` on `CardBoxesSchema`.
124
+ */
125
+ export const RotationSchema = z.union([
126
+ z.literal(90),
127
+ z.literal(180),
128
+ z.literal(270),
129
+ ]);
38
130
  export const CardBoxesSchema = z.object({
131
+ /**
132
+ * How far the card is turned, in degrees clockwise, to read this set: 90 for
133
+ * a battle or a split card, whose text runs up the sheet. Left out, the set
134
+ * reads upright.
135
+ *
136
+ * A turned set's boxes — `art` included — are measured on the sheet as it
137
+ * lies after that turn, so a text box stays the upright rectangle it is
138
+ * everywhere else. At 90 or 270 the default sheet is therefore 4440 wide and
139
+ * 3264 tall. The art is drawn turned with the set.
140
+ *
141
+ * `collectorInfo` is the exception: a card prints it upright along the
142
+ * sheet's bottom edge whichever way the text above it reads, so it is always
143
+ * measured on the unturned sheet. Frame art and masks never turn either —
144
+ * they are the sheet as printed.
145
+ */
146
+ rotation: RotationSchema.optional(),
39
147
  art: BoundsSchema,
148
+ /**
149
+ * Where the card's first art goes once it has a second one, which goes in
150
+ * the second face's `art` (see `SecondFaceBoxesSchema`). Left out, the first
151
+ * art keeps `art`. A card has one art until a second is added, so `art` is
152
+ * still what every card is imported with.
153
+ */
154
+ firstArt: BoundsSchema.optional(),
40
155
  mana: TextBoxSchema,
41
156
  title: TextBoxSchema,
42
157
  type: TextBoxSchema,
43
- setSymbol: BoundsSchema,
158
+ setSymbol: SetSymbolBoxSchema,
44
159
  rules: TextBoxSchema.optional(),
45
160
  abilities: TextBoxSchema.optional(),
46
161
  pt: TextBoxSchema.optional(),
47
162
  ptImage: z.object({ x: z.number(), y: z.number() }).optional(),
48
- collectorInfo: TextBoxSchema.optional(),
163
+ /**
164
+ * Power and toughness each in a box of its own, for a frame that prints
165
+ * them apart rather than as one `power/toughness`. Both are needed; with
166
+ * them, `pt` is not drawn.
167
+ */
168
+ power: TextBoxSchema.optional(),
169
+ toughness: TextBoxSchema.optional(),
170
+ collectorInfo: CollectorInfoBoxSchema.optional(),
49
171
  startingLoyalty: TextBoxSchema.optional(),
50
172
  nicknameTitle: TextBoxSchema.optional(),
51
173
  keyword: TextBoxSchema.optional(),
@@ -61,6 +183,62 @@ export const CardBoxesSchema = z.object({
61
183
  // when the other face is a land — lands have no mana cost, so the
62
184
  // flipsideManaCost badge is empty and the rules text fills that space.
63
185
  flipsideRules: TextBoxSchema.optional(),
186
+ // Adventure, Omen and Prepare cards print a second spell in a smaller frame
187
+ // inset within the text box. Its name, mana cost, type line and rules text
188
+ // come from the card's second face; `title`, `mana`, `type` and `rules` keep
189
+ // describing the card itself, with `rules` narrowed to the column the inset
190
+ // leaves free. Which side the inset sits on is the layout's own geometry —
191
+ // left for an Adventure or an Omen, right for a Prepare card — so the
192
+ // renderer draws these wherever they are put.
193
+ insetTitle: TextBoxSchema.optional(),
194
+ insetMana: TextBoxSchema.optional(),
195
+ insetType: TextBoxSchema.optional(),
196
+ insetRules: TextBoxSchema.optional(),
197
+ // A battle's defense, printed where a creature's power and toughness would
198
+ // be. Rules text wraps around it the way it does around `pt`.
199
+ defense: TextBoxSchema.optional(),
200
+ // A Fuse card prints its Fuse line once, in a bar under both halves, though
201
+ // Scryfall repeats it at the end of each half's rules. With this box the
202
+ // line is drawn here and dropped from both; without it, it stays in each
203
+ // half's rules.
204
+ fuse: TextBoxSchema.optional(),
205
+ });
206
+ /**
207
+ * Where a card's second face goes when it is printed on the same side as the
208
+ * first: the other half of a split card, the sideways half of an aftermath
209
+ * card, the upside-down half of a flip card. Its name, mana cost, type line,
210
+ * rules text and power/toughness come from the card's second face, each drawn
211
+ * where its box is and left out where there is none.
212
+ *
213
+ * A set of its own rather than more members of `CardBoxes` because it can be
214
+ * turned on its own: `rotation` means what it does there, and is likewise
215
+ * measured against the sheet, not against the first face's set. A split card
216
+ * turns both sets 90; an aftermath card leaves the first upright and turns
217
+ * this one 270; a flip card turns this one 180.
218
+ *
219
+ * The card has one art, placed by the first face's `art`, unless this set has
220
+ * an `art` of its own and the card has been given a second art.
221
+ */
222
+ export const SecondFaceBoxesSchema = z.object({
223
+ rotation: RotationSchema.optional(),
224
+ /**
225
+ * Where a second art goes: measured like the rest of this set, and drawn
226
+ * turned with it, so it reads the way this face's text does. A layout whose
227
+ * second face has one can take a second art; while a card has one, its first
228
+ * art moves to the first face's `firstArt`. The two are split where they
229
+ * meet — a straight seam midway between them — and each runs on past its
230
+ * other edges under the frame, as a lone art does.
231
+ */
232
+ art: BoundsSchema.optional(),
233
+ title: TextBoxSchema,
234
+ mana: TextBoxSchema.optional(),
235
+ type: TextBoxSchema,
236
+ setSymbol: SetSymbolBoxSchema.optional(),
237
+ rules: TextBoxSchema.optional(),
238
+ pt: TextBoxSchema.optional(),
239
+ /** Power and toughness apart, as on the first face's set. */
240
+ power: TextBoxSchema.optional(),
241
+ toughness: TextBoxSchema.optional(),
64
242
  });
65
243
  /**
66
244
  * Configuration for planeswalker ability rendering.
@@ -77,17 +255,91 @@ export const PlaneswalkerRenderConfigSchema = z.object({
77
255
  staticTextPaddingX: z.number(),
78
256
  /** Right padding for all ability text in pixels */
79
257
  textPaddingRight: z.number().optional(),
80
- /** Top padding before the first ability in pixels (background still fills from box top) */
258
+ /**
259
+ * Top padding before the first ability in pixels. Unused since 1.12: the
260
+ * abilities are spaced by `abilityFill`.
261
+ */
81
262
  topPadding: z.number().optional(),
82
- /** Bottom padding after the last ability in pixels (background still fills to box bottom) */
83
- bottomPadding: z.number().optional(),
84
263
  /**
85
- * Fraction of the available height at which the shrink loop starts
86
- * shrinking the font. 1.0 = only shrink when text literally doesn't fit.
87
- * Set lower (e.g. 0.85) to shrink sooner when abilities have more text,
88
- * giving more breathing room between bands. Defaults to 1.0.
264
+ * Bottom padding after the last ability in pixels. Unused since 1.12: see
265
+ * `loyaltyReserve`.
89
266
  */
267
+ bottomPadding: z.number().optional(),
268
+ /** Unused since 1.12: the font is fitted to `abilityFill`. */
90
269
  textFillThreshold: z.number().optional(),
270
+ /**
271
+ * The most of the abilities' height their text may take, 0–1, at
272
+ * `abilityFillSize`; the rest is shared out evenly as padding around each
273
+ * ability. The font shrinks in quarter printed points (3.125 of the sheet's
274
+ * points), from the largest at or below the abilities box's `fontSize` down
275
+ * to `minFontSize`, until the text fits it. Printed M15 planeswalkers fill
276
+ * about 0.62 at 88. Defaults to 0.62.
277
+ */
278
+ abilityFill: z.number().optional(),
279
+ /**
280
+ * The size `abilityFill` holds at, in points. Smaller text may fill more of
281
+ * the height and larger text less, in proportion: printed M15 planeswalkers
282
+ * with little text set it larger, with more room around it. Defaults to 88.
283
+ */
284
+ abilityFillSize: z.number().optional(),
285
+ /** The smallest size the abilities' font shrinks to, in points. Defaults to 70. */
286
+ minFontSize: z.number().optional(),
287
+ /**
288
+ * The abilities' line height as a multiple of their font size. Printed M15
289
+ * planeswalkers set about 1.32. Defaults to 1.32.
290
+ */
291
+ lineHeightRatio: z.number().optional(),
292
+ /**
293
+ * The least height a loyalty ability takes, in px, however short its text,
294
+ * so its badge has room. Defaults to 200.
295
+ */
296
+ badgeSlotHeight: z.number().optional(),
297
+ /**
298
+ * Height at the abilities box's bottom, in px, that the abilities are laid
299
+ * out above: the room beside the starting loyalty. The last ability's band
300
+ * still reaches the box's bottom, and its text can run into the reserve
301
+ * where it clears the badge. Defaults to 60.
302
+ */
303
+ loyaltyReserve: z.number().optional(),
304
+ /**
305
+ * How far each separator sits below where its two bands meet, in px. The
306
+ * separator's wave is the top edge a band shows, so this moves where an
307
+ * ability looks to start inside its band; its text stays put. Defaults to
308
+ * 24, where printed M15 planeswalkers have it.
309
+ */
310
+ separatorOffsetY: z.number().optional(),
311
+ /**
312
+ * Draw the translucent bands (and separators) behind the abilities.
313
+ * Defaults to true. Turn it off for a frame whose art shows through the
314
+ * ability box, and give the abilities box an outline so the text reads.
315
+ */
316
+ abilityBacking: z.boolean().optional(),
317
+ /**
318
+ * A rule painted where each two abilities meet, as a layout's `gradients`
319
+ * are: over the art, under the text. Its `y` is measured from where they
320
+ * meet rather than from the sheet's top, so `-height / 2` centers it there.
321
+ * For a frame without bands, whose art shows through the ability box.
322
+ */
323
+ abilityDivider: GradientSchema.omit({
324
+ anchor: true,
325
+ slides: true,
326
+ when: true,
327
+ }).optional(),
328
+ /**
329
+ * How far each loyalty badge sits below the center of its ability, in px.
330
+ * Defaults suit M15's badges — plus 0, minus 34, zero 16 — so a badge whose
331
+ * point hangs below its body sits low enough for the body to center.
332
+ */
333
+ plusBadgeOffsetY: z.number().optional(),
334
+ minusBadgeOffsetY: z.number().optional(),
335
+ zeroBadgeOffsetY: z.number().optional(),
336
+ /**
337
+ * How far the loyalty cost on each badge sits below the center of its
338
+ * ability, in px — moves the number alone, not its badge. Defaults to 0.
339
+ */
340
+ plusCostOffsetY: z.number().optional(),
341
+ minusCostOffsetY: z.number().optional(),
342
+ zeroCostOffsetY: z.number().optional(),
91
343
  });
92
344
  /**
93
345
  * Default planeswalker render config values, used when a frame template
@@ -129,6 +381,92 @@ export const defaultSagaConfig = {
129
381
  textPaddingX: 220,
130
382
  badgeWidth: 180,
131
383
  };
384
+ /**
385
+ * Configuration for a column of sections — a Class's levels, a Case's steps.
386
+ * Frame templates provide this via LayoutConfig.sectionConfig.
387
+ */
388
+ export const SectionRenderConfigSchema = z.object({
389
+ /**
390
+ * Leave out a Class's opening reminder ("Gain the next level as a sorcery
391
+ * to add its ability.") and start the column at level 1, as
392
+ * `SagaRenderConfig.skipReminder` does for a Saga's.
393
+ */
394
+ skipReminder: z.boolean().optional(),
395
+ });
396
+ /**
397
+ * Where one of a layout's fixed dividers goes: the frame's `sectionDivider`,
398
+ * drawn `length` long from (`x`, `y`) — rightwards for a horizontal one,
399
+ * downwards for a vertical one, turned a quarter. `x`, `y` is on the line's
400
+ * centre across its thickness, which the divider's own aspect ratio sets.
401
+ * Measured on the sheet.
402
+ */
403
+ export const SectionDividerPlacementSchema = z.object({
404
+ x: z.number(),
405
+ y: z.number(),
406
+ length: z.number(),
407
+ orientation: z.enum(["horizontal", "vertical"]).optional(),
408
+ });
409
+ /**
410
+ * Moves the boxes above the rules text down when the text leaves room, so
411
+ * short text shows more art — in whole `step`s, never more than `maxSteps`.
412
+ *
413
+ * The room is the `rules` box's height less the fitted text's and `padding`.
414
+ * The listed `boxes`, every gradient anchored to one of them, and every
415
+ * gradient and tinted image marked `slides`, go down by the slide; the
416
+ * `rules` box's top goes down with them while its bottom stays, so the text,
417
+ * centered in it, goes down by half. Everything else — collector info, power
418
+ * and toughness — stays put.
419
+ *
420
+ * Only a card whose rules are drawn in the `rules` box slides: not a
421
+ * planeswalker, Saga, Class or Case.
422
+ */
423
+ export const RulesSlideSchema = z.object({
424
+ padding: z.number().nonnegative(),
425
+ step: z.number().positive(),
426
+ maxSteps: z.number().int().nonnegative(),
427
+ boxes: z.array(SlidingBoxEnum).readonly(),
428
+ });
429
+ /**
430
+ * Power and toughness laid out as a row from its right edge, for a frame that
431
+ * prints them apart: the toughness ends at `right`, its symbol sits `gap`
432
+ * left of it, the power ends `spacing` left of that symbol, and the power's
433
+ * symbol sits `gap` left of the power. A wider number — `10`, `100`, `*`
434
+ * — moves everything left of it.
435
+ *
436
+ * The symbols are the tinted images marked `ptSlot`. The `power` and
437
+ * `toughness` boxes still give each number's font, size, color and height;
438
+ * only their `x` and `width` are worked out. First face only, as tinted
439
+ * images are.
440
+ *
441
+ * With no symbol drawn in either slot, say when a setting turns them off, the
442
+ * two print as one `P/T` ending at `right`, in the `toughness` box's style.
443
+ */
444
+ export const PowerToughnessRowSchema = z.object({
445
+ right: z.number(),
446
+ gap: z.number(),
447
+ spacing: z.number(),
448
+ });
449
+ /** The five colors and colorless, the keys of a mana symbol color map. */
450
+ export const ManaColorEnum = z.enum(["W", "U", "B", "R", "G", "C"]);
451
+ /**
452
+ * How the card's mana symbols are drawn, wherever they appear: the mana cost,
453
+ * rules text, abilities, and the keyword and flipside boxes alike.
454
+ */
455
+ export const ManaSymbolStyleSchema = z.object({
456
+ /**
457
+ * `"filled"` is the usual solid pip. `"hollow"` draws the same symbols as a
458
+ * ring with the glyph inside it, and a hybrid as two half rings.
459
+ */
460
+ style: z.enum(["filled", "hollow"]).optional(),
461
+ /** The pip (or ring) color per mana color, in place of the app's. */
462
+ colors: z.partialRecord(ManaColorEnum, HexColorSchema).optional(),
463
+ /**
464
+ * The glyph color per mana color, in place of the app's black. `C` is also
465
+ * the glyph color of every symbol of no color: generic mana, `{C}`, `{T}`,
466
+ * `{X}` and the like.
467
+ */
468
+ glyphColors: z.partialRecord(ManaColorEnum, HexColorSchema).optional(),
469
+ });
132
470
  const CrownRenderConfigSchema = z.object({
133
471
  x: z.number(),
134
472
  y: z.number(),
@@ -155,25 +493,35 @@ export const defaultNicknameConfig = {
155
493
  x: 378,
156
494
  y: 577,
157
495
  };
496
+ /**
497
+ * A hint rendered under a template setting's control, for anything the label
498
+ * alone can't carry — what the setting changes, or when to reach for it.
499
+ * Optional everywhere; a setting without one renders exactly as before.
500
+ */
501
+ const helperText = z.string().optional();
158
502
  export const TemplateSettingConfigSchema = z.union([
159
503
  z.object({
160
504
  type: z.literal("boolean"),
161
505
  label: z.string(),
506
+ helperText,
162
507
  defaultValue: z.boolean(),
163
508
  }),
164
509
  z.object({
165
510
  type: z.literal("string"),
166
511
  label: z.string(),
512
+ helperText,
167
513
  defaultValue: z.string(),
168
514
  }),
169
515
  z.object({
170
516
  type: z.literal("number"),
171
517
  label: z.string(),
518
+ helperText,
172
519
  defaultValue: z.string(),
173
520
  }),
174
521
  z.object({
175
522
  type: z.literal("select"),
176
523
  label: z.string(),
524
+ helperText,
177
525
  defaultValue: z.string(),
178
526
  options: z.array(z.string()),
179
527
  }),
@@ -184,10 +532,37 @@ export const LayoutConfigSchema = z.object({
184
532
  tallBoxes: CardBoxesSchema.optional(),
185
533
  /** Boxes for 2-ability planeswalkers (overrides `boxes` when set). */
186
534
  twoAbilityBoxes: CardBoxesSchema.optional(),
535
+ /**
536
+ * The second face of a card that prints both faces on one side — split,
537
+ * aftermath and flip cards. Drawn alongside `boxes`, which keeps describing
538
+ * the first face. See `SecondFaceBoxesSchema`.
539
+ */
540
+ secondFaceBoxes: SecondFaceBoxesSchema.optional(),
187
541
  frameAssets: FrameAssetsSchema,
188
542
  masks: LayoutMasksSchema.optional(),
543
+ /**
544
+ * Masks for the planeswalker tall frame, used instead of `masks` wherever
545
+ * `tallBoxes` is. The tall frame draws no color overlays, so `noBorder` is
546
+ * the one that matters.
547
+ */
548
+ tallMasks: LayoutMasksSchema.optional(),
189
549
  planeswalkerConfig: PlaneswalkerRenderConfigSchema.optional(),
190
550
  sagaConfig: SagaRenderConfigSchema.optional(),
551
+ sectionConfig: SectionRenderConfigSchema.optional(),
552
+ /**
553
+ * Dividers that stay put whatever the card says — an inset spell's rules
554
+ * set off from its name and type line, and the inset from the card's own
555
+ * rules beside it. Drawn with `frameAssets.sectionDivider`, so a layout
556
+ * without one draws none.
557
+ */
558
+ sectionDividers: z.array(SectionDividerPlacementSchema).optional(),
559
+ /** Painted over the art and under the text, in order. See `GradientSchema`. */
560
+ gradients: z.array(GradientSchema).readonly().optional(),
561
+ /** Shapes painted in the card's colors. See `TintedImageSchema`. */
562
+ tintedImages: z.array(TintedImageSchema).readonly().optional(),
563
+ rulesSlide: RulesSlideSchema.optional(),
564
+ powerToughnessRow: PowerToughnessRowSchema.optional(),
565
+ manaSymbols: ManaSymbolStyleSchema.optional(),
191
566
  crownConfig: CrownRenderConfigSchema.optional(),
192
567
  nicknameConfig: NicknameRenderConfigSchema.optional(),
193
568
  templateSettings: TemplateSettingsConfigSchema.optional(),
@@ -300,12 +675,39 @@ export const TemplateConfigSchema = z.object({
300
675
  // opt-in variants — each entry is a full `LayoutConfig` for a layout that
301
676
  // already exists in `layouts`, the same way `saga_creature` is a full
302
677
  // standalone config. The active variant is chosen by the `frameVariant`
303
- // template setting; see `getLayoutConfig`.
678
+ // template setting; see `getLayoutConfig`. A layout's own select setting
679
+ // can name one too, for that layout alone: Borderless's "Adventure" setting
680
+ // set to "inset" draws an Adventure from `alternateLayouts.inset.adventure`.
681
+ // The frame variant wins where both name a config for the card's layout.
304
682
  alternateLayouts: z
305
683
  .record(z.string(), z.partialRecord(LayoutEnum, LayoutConfigSchema))
306
684
  .optional(),
307
685
  disabledFields: z.array(z.enum(["setSymbol", "collectorInfo"])).optional(),
308
686
  });
687
+ /**
688
+ * A font the frame ships, for its text boxes to name in `fontFamily`.
689
+ *
690
+ * `family` is the frame's own name for it and only means something inside
691
+ * this frame: the app keeps each frame's fonts apart, so a frame can neither
692
+ * replace a font the app has (a `family` of "Beleren" is still the frame's
693
+ * file, not the app's) nor clash with another frame's. A box naming a family
694
+ * the frame does not ship gets the app's font of that name, as before.
695
+ *
696
+ * Ship only a font whose license lets it be redistributed. One entry per
697
+ * weight and style; a family with a bold face lists it as a second entry.
698
+ */
699
+ export const FrameFontSchema = z.object({
700
+ family: z.string().min(1),
701
+ src: AssetUrlSchema,
702
+ weight: z.enum(["normal", "bold"]).optional(),
703
+ style: z.enum(["normal", "italic"]).optional(),
704
+ /**
705
+ * The font's PostScript name, which a PSD export names its text layers by
706
+ * so Photoshop finds the font when it is installed. Left out, a PSD export
707
+ * leaves text in this font as pixels rather than a text layer.
708
+ */
709
+ postScriptName: z.string().optional(),
710
+ });
309
711
  export const FrameSchema = z.object({
310
712
  name: z.string(),
311
713
  description: z.string(),
@@ -313,6 +715,8 @@ export const FrameSchema = z.object({
313
715
  tags: z.array(z.string()),
314
716
  disabled: z.boolean().optional(),
315
717
  templateSettings: TemplateSettingsConfigSchema.optional(),
718
+ /** Fonts the frame ships. See `FrameFontSchema`. */
719
+ fonts: z.array(FrameFontSchema).readonly().optional(),
316
720
  config: TemplateConfigSchema,
317
721
  });
318
722
  /** Whether a frame exposes any template settings (frame-level or per-layout). */