@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
package/README.md CHANGED
@@ -43,6 +43,30 @@ export const myFrame = {
43
43
  `as const satisfies Frame` is the idiom: `satisfies` checks the object against the contract, `as const`
44
44
  keeps the literal types.
45
45
 
46
+ ### The collector line
47
+
48
+ What the collector box prints is data too: its `lines`, each a row of elements (`rarity`, `cardNumber`,
49
+ `setCode`, `artist`, …) with their own spacing, sizing, fonts and alignment. `withCollectorInfoDefaults`
50
+ fills in `defaultCollectorInfoLines` unless the bounds bring their own. Pass `retroCollectorInfoLines`
51
+ for the Retro frame's centered artist and creator lines, or write your own:
52
+
53
+ ```ts
54
+ const collectorBounds = {
55
+ x: 460,
56
+ y: 3880,
57
+ width: 2330,
58
+ height: 200,
59
+ fontSize: 58,
60
+ lines: [
61
+ { elements: [{ type: "artist", prefix: "Illus. " }], align: "center" },
62
+ {
63
+ elements: [{ type: "setCode", spacingAfter: 24 }, { type: "cardNumber" }],
64
+ align: "center",
65
+ },
66
+ ],
67
+ } as const satisfies CollectorInfoBox;
68
+ ```
69
+
46
70
  ## Entry points
47
71
 
48
72
  | Import | What it gives you |
@@ -58,6 +82,7 @@ frame-kit validate # check every frame, write nothing
58
82
  frame-kit build --out dist # check, then pack each one
59
83
  frame-kit build --out dist --watch # …and repack whenever a source changes
60
84
  frame-kit schema meta # a format as JSON Schema
85
+ frame-kit trim --shared a.png b.png # cut art down to what is visible
61
86
  frame-kit --help
62
87
  ```
63
88
 
@@ -129,6 +154,100 @@ Packaging needs Vite — it is what turns `import w from "./w.png"` into a file
129
154
  so it is an **optional** peer dependency, loaded only when packaging runs. The
130
155
  schemas above work under plain Node and in a browser without it.
131
156
 
157
+ ### `frame-kit trim`
158
+
159
+ ```bash
160
+ frame-kit trim [--shared] [--out <dir>] [--dry-run] [--json] <png...>
161
+ ```
162
+
163
+ Cuts PNGs down to their visible pixels and prints where each now sits on the
164
+ sheet. Art is authored on the full sheet so it lines up by itself, but a
165
+ renderer decodes, caches and composites every pixel of an image, transparent or
166
+ not — a Saga banner is under 5% of its sheet, and trimmed it costs a twentieth
167
+ as much on every card.
168
+
169
+ `--shared` cuts every image given to one rectangle, the smallest holding all of
170
+ their visible pixels, so images drawn together — a banner and the masks cut from
171
+ it — keep one position. Put that position in the frame wherever a field asks for
172
+ one, such as `saga.bannerOrigin`; only fields that take a position can use
173
+ trimmed art.
174
+
175
+ The pixels kept are byte-for-byte the originals, and the chunks that tell a
176
+ decoder how to show them — colour profile, gamma — are carried over, so the art
177
+ draws exactly as before. Each trimmed image also records its position in an
178
+ `oFFs` chunk, so trimming it again reports where it sits on the sheet rather
179
+ than in itself. Without `--out` each image replaces its original. Only 8-bit
180
+ PNGs are trimmed: decoding 16 bits a channel would lose detail.
181
+
182
+ ### `frame-kit studio`
183
+
184
+ ```bash
185
+ frame-kit studio [--root <dir>] [--port <n>] [--frame <slug>] [--no-write]
186
+ ```
187
+
188
+ A visual box editor, served from `dist/studio` and opened at
189
+ <http://localhost:4620>. It loads frames through the same Vite SSR loader
190
+ `validate` uses, so what it draws is the validated frame, and it reloads when a
191
+ source file changes.
192
+
193
+ The card name and type line are previewed in Beleren at the authored size and
194
+ flagged when they overflow — one line each, with no wrapping, symbols or
195
+ auto-shrink, so the app remains the authority on a full render.
196
+
197
+ Working on the studio's own UI: `--dev` serves it from source with hot
198
+ reloading, on the same port as the API, so one command covers both. It needs
199
+ the UI's source, which only exists inside the frames workspace.
200
+
201
+ Dragging a box rewrites the literal it came from, in place — nothing is
202
+ reprinted, so comments and formatting survive. Editing needs `typescript`
203
+ installed (an optional peer); without it the studio still runs read-only. Values
204
+ that are not written as literals are reported as such rather than edited.
205
+
206
+ ### `@cardanvil/frame-kit/layout`
207
+
208
+ How a frame is drawn for one card, as pure functions:
209
+
210
+ - `selectFrameLayers(details, options)` — the base frame and section overlays a
211
+ set of `FrameDetails` selects. Split out of Card Anvil's `getFrameLayers`, so
212
+ the studio and the renderer cannot drift on two-colour splits, hybrid halves
213
+ or the coloured-artifact fallback.
214
+ - `frameDetailsFor(shape)` — those details from a plain description of a card
215
+ (colours plus artifact / vehicle / land / enchantment / hybrid / devoid),
216
+ for callers with no Scryfall card to parse.
217
+ - `placeAsset(path, config)` — where a piece of art is drawn. Most frame art is
218
+ full-sheet and centres, but crowns, nicknames and PT plates come from the
219
+ layout's own render config; centring those puts a legendary crown in the
220
+ middle of the card.
221
+ - `resolveFrameAsset` / `resolveBaseFrame` — exact per-family lookup, and the
222
+ base-frame fallback chain.
223
+ - `frameCutoutMasks(input)` — the masks cut out of a face's frame body, in the
224
+ renderer's order: "No Border", the legendary crown's space, the extended-art
225
+ silhouette, the colorless nickname banner.
226
+ - `ringMaskFor(masks, useFullBorder)` and `borderStateFor(input)` — the mask a
227
+ border color recolors through, and the border state that results:
228
+ `default`, `light`, `dark` or `none`.
229
+ - `resolveBoxOverrides(boxes, state)` — a box set with each text box's
230
+ `overrides` merged in for one render's border state and template settings,
231
+ so everything that draws a frame agrees on what a condition matches.
232
+ `overrideMatches` tests a single rule, and
233
+ `resolveSecondFaceBoxOverrides` does the same for a `secondFaceBoxes` set.
234
+ - `turnPlacement(sheet, rotation)` / `turnMatrix(sheet, rotation)` — where a
235
+ turned box set (one with a `rotation`) sits on the sheet, as a node's
236
+ position and rotation or as a matrix. `turnedSheetSize` is the sheet as such
237
+ a set measures it, `turnedBoundsOnSheet` one of its boxes back on the sheet
238
+ as printed, and `turnedVectorOnSheet` / `sheetVectorInTurned` a direction
239
+ taken one way or the other.
240
+ - `templateSettingDefaults(frame)`, `mergeTemplateSettings` and
241
+ `layoutTemplateSettings(settings, layout)` — a frame's settings the way Card
242
+ Anvil stores them, and the values in effect for one layout.
243
+ - `textOutlineFor(box)` / `textShadowFor(box)` — how a plain-text box is
244
+ outlined or shadowed.
245
+ - `getReadableTextColor` and friends — the contrast rule that decides whether
246
+ a border color reads as light or dark.
247
+
248
+ Card Anvil still owns the other half — reading a Scryfall card into
249
+ `FrameDetails` — because that is where the Scryfall types live.
250
+
132
251
  ## Type it, then validate it
133
252
 
134
253
  Typing a config as `Frame` is weaker than the schema a loading app enforces. The clearest example:
@@ -0,0 +1,17 @@
1
+ import { CollectorInfoLine } from "../schema/collectorInfo.js";
2
+ /**
3
+ * Default collector info layout with two lines, left-aligned.
4
+ * Line 1: rarity | cardNumber | creatorName
5
+ * Line 2: setCode | separator | language | artistSymbol | artist
6
+ *
7
+ * `withCollectorInfoDefaults` gives these to collector info unless its bounds
8
+ * bring their own `lines`, and the renderer falls back to them for a collector
9
+ * box without any.
10
+ */
11
+ export declare const defaultCollectorInfoLines: readonly CollectorInfoLine[];
12
+ /**
13
+ * Retro collector info layout: just artist (top line) and creator name
14
+ * (bottom line), both centered in the box instead of flowing from its left
15
+ * edge like the default lines do.
16
+ */
17
+ export declare const retroCollectorInfoLines: readonly CollectorInfoLine[];
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Default collector info layout with two lines, left-aligned.
3
+ * Line 1: rarity | cardNumber | creatorName
4
+ * Line 2: setCode | separator | language | artistSymbol | artist
5
+ *
6
+ * `withCollectorInfoDefaults` gives these to collector info unless its bounds
7
+ * bring their own `lines`, and the renderer falls back to them for a collector
8
+ * box without any.
9
+ */
10
+ export const defaultCollectorInfoLines = [
11
+ {
12
+ elements: [
13
+ { type: "rarity", spacingAfter: 34 }, // Extra space before card number
14
+ { type: "cardNumber", spacingAfter: 40, letterSpacing: 8 }, // Extra space before creator name
15
+ { type: "creatorName", minXFromElement: "artistSymbol" },
16
+ ],
17
+ spacing: 10, // Space between elements
18
+ },
19
+ {
20
+ elements: [
21
+ { type: "setCode", spacingAfter: 24, letterSpacing: 6 }, // Extra space before language
22
+ {
23
+ type: "separator",
24
+ useManaFont: true,
25
+ manaSymbol: "•",
26
+ fontSizeMultiplierDot: 1.9,
27
+ fontSizeMultiplierStar: 1.4,
28
+ yOffsetDot: 5,
29
+ yOffsetStar: 20,
30
+ spacingAfter: 10,
31
+ },
32
+ { type: "language", spacingAfter: 38, letterSpacing: 4 }, // Extra space before artist symbol
33
+ {
34
+ type: "artistSymbol",
35
+ useManaFont: true,
36
+ manaSymbol: "a",
37
+ fontSizeMultiplier: 1.24,
38
+ },
39
+ { type: "artist", fontSizeMultiplier: 1.1, letterSpacing: 0 },
40
+ ],
41
+ spacing: 10,
42
+ },
43
+ ];
44
+ /**
45
+ * Retro collector info layout: just artist (top line) and creator name
46
+ * (bottom line), both centered in the box instead of flowing from its left
47
+ * edge like the default lines do.
48
+ */
49
+ export const retroCollectorInfoLines = [
50
+ {
51
+ elements: [{ type: "artist", prefix: "Illus. " }],
52
+ align: "center",
53
+ fontSizeMultiplier: 1.5,
54
+ fontFamily: "MPlantin",
55
+ },
56
+ {
57
+ elements: [{ type: "creatorName" }],
58
+ align: "center",
59
+ fontSizeMultiplier: 1.3,
60
+ fontFamily: "MPlantin",
61
+ },
62
+ ];
@@ -1,2 +1,4 @@
1
+ export * from "./collectorInfo.js";
1
2
  export * from "./layoutDefaults.js";
2
3
  export * from "./omit.js";
4
+ export * from "./opacityStops.js";
@@ -1,2 +1,4 @@
1
+ export * from "./collectorInfo.js";
1
2
  export * from "./layoutDefaults.js";
2
3
  export * from "./omit.js";
4
+ export * from "./opacityStops.js";
@@ -1,6 +1,24 @@
1
- import { LayoutConfig, TemplateSettingsConfig, TextBox } from "../schema/frame.js";
1
+ import { CollectorInfoBox, LayoutConfig, TemplateSettingsConfig, TextBoxOverride } from "../schema/frame.js";
2
+ /**
3
+ * `overrides` for text printed on the frame's outer border ring — on most
4
+ * frames, the collector line. The text turns black once a light border color
5
+ * is picked, and gains a black outline when "No Border" cuts the ring away and
6
+ * leaves it over the art.
7
+ *
8
+ * `withCollectorInfoDefaults` gives these to collector info unless its bounds
9
+ * bring their own.
10
+ */
11
+ export declare const onBorderTextOverrides: readonly TextBoxOverride[];
2
12
  /**
3
13
  * Applies default collector info bounds to a layout config if not specified.
14
+ *
15
+ * The collector info this fills in is taken to sit on the border ring, so it
16
+ * gets `onBorderTextOverrides` — unless `defaultCollectorInfoBounds` brings its
17
+ * own `overrides`, with `[]` for a collector line printed where the border
18
+ * settings never reach. It prints `defaultCollectorInfoLines` unless the bounds
19
+ * bring their own `lines` — `retroCollectorInfoLines`, or a layout of the
20
+ * frame's own. A collector box the layout defines itself is left exactly as
21
+ * written.
4
22
  */
5
- export declare function withCollectorInfoDefaults<T extends LayoutConfig>(config: T, defaultCollectorInfoBounds: TextBox): T;
23
+ export declare function withCollectorInfoDefaults<T extends LayoutConfig>(config: T, defaultCollectorInfoBounds: CollectorInfoBox): T;
6
24
  export declare function withSettingsDefaults<T extends LayoutConfig>(config: T, defaultSettings: TemplateSettingsConfig): T;
@@ -1,62 +1,97 @@
1
+ import { defaultCollectorInfoLines } from "./collectorInfo.js";
2
+ /**
3
+ * `overrides` for text printed on the frame's outer border ring — on most
4
+ * frames, the collector line. The text turns black once a light border color
5
+ * is picked, and gains a black outline when "No Border" cuts the ring away and
6
+ * leaves it over the art.
7
+ *
8
+ * `withCollectorInfoDefaults` gives these to collector info unless its bounds
9
+ * bring their own.
10
+ */
11
+ export const onBorderTextOverrides = [
12
+ {
13
+ when: { border: "none" },
14
+ style: { outlineColor: "black", outlineWidth: 19 },
15
+ },
16
+ { when: { border: "light" }, style: { color: "black" } },
17
+ ];
1
18
  /**
2
19
  * Applies default collector info bounds to a layout config if not specified.
20
+ *
21
+ * The collector info this fills in is taken to sit on the border ring, so it
22
+ * gets `onBorderTextOverrides` — unless `defaultCollectorInfoBounds` brings its
23
+ * own `overrides`, with `[]` for a collector line printed where the border
24
+ * settings never reach. It prints `defaultCollectorInfoLines` unless the bounds
25
+ * bring their own `lines` — `retroCollectorInfoLines`, or a layout of the
26
+ * frame's own. A collector box the layout defines itself is left exactly as
27
+ * written.
3
28
  */
4
29
  export function withCollectorInfoDefaults(config, defaultCollectorInfoBounds) {
30
+ const collectorInfo = {
31
+ ...defaultCollectorInfoBounds,
32
+ overrides: defaultCollectorInfoBounds.overrides ?? onBorderTextOverrides,
33
+ lines: defaultCollectorInfoBounds.lines ?? defaultCollectorInfoLines,
34
+ };
5
35
  return {
6
36
  ...config,
7
37
  boxes: {
8
38
  ...config.boxes,
9
- collectorInfo: config.boxes.collectorInfo ?? defaultCollectorInfoBounds,
39
+ collectorInfo: config.boxes.collectorInfo ?? collectorInfo,
10
40
  },
11
41
  tallBoxes: config.tallBoxes
12
42
  ? {
13
43
  ...config.tallBoxes,
14
- collectorInfo: config.tallBoxes.collectorInfo ?? defaultCollectorInfoBounds,
44
+ collectorInfo: config.tallBoxes.collectorInfo ?? collectorInfo,
45
+ }
46
+ : undefined,
47
+ // Planeswalkers with two abilities swap `boxes` for these (see
48
+ // renderCardToCanvas), which would otherwise drop collector info.
49
+ twoAbilityBoxes: config.twoAbilityBoxes
50
+ ? {
51
+ ...config.twoAbilityBoxes,
52
+ collectorInfo: config.twoAbilityBoxes.collectorInfo ?? collectorInfo,
15
53
  }
16
54
  : undefined,
17
55
  backBoxes: config.backBoxes
18
56
  ? {
19
57
  ...config.backBoxes,
20
- collectorInfo: config.backBoxes.collectorInfo ?? defaultCollectorInfoBounds,
58
+ collectorInfo: config.backBoxes.collectorInfo ?? collectorInfo,
21
59
  }
22
60
  : undefined,
23
61
  sagaFrontBoxes: config.sagaFrontBoxes
24
62
  ? {
25
63
  ...config.sagaFrontBoxes,
26
- collectorInfo: config.sagaFrontBoxes.collectorInfo ?? defaultCollectorInfoBounds,
64
+ collectorInfo: config.sagaFrontBoxes.collectorInfo ?? collectorInfo,
27
65
  }
28
66
  : undefined,
29
67
  sagaBackBoxes: config.sagaBackBoxes
30
68
  ? {
31
69
  ...config.sagaBackBoxes,
32
- collectorInfo: config.sagaBackBoxes.collectorInfo ?? defaultCollectorInfoBounds,
70
+ collectorInfo: config.sagaBackBoxes.collectorInfo ?? collectorInfo,
33
71
  }
34
72
  : undefined,
35
73
  creatureBoxes: config.creatureBoxes
36
74
  ? {
37
75
  ...config.creatureBoxes,
38
- collectorInfo: config.creatureBoxes.collectorInfo ?? defaultCollectorInfoBounds,
76
+ collectorInfo: config.creatureBoxes.collectorInfo ?? collectorInfo,
39
77
  }
40
78
  : undefined,
41
79
  backCreatureBoxes: config.backCreatureBoxes
42
80
  ? {
43
81
  ...config.backCreatureBoxes,
44
- collectorInfo: config.backCreatureBoxes.collectorInfo ??
45
- defaultCollectorInfoBounds,
82
+ collectorInfo: config.backCreatureBoxes.collectorInfo ?? collectorInfo,
46
83
  }
47
84
  : undefined,
48
85
  sagaFrontCreatureBoxes: config.sagaFrontCreatureBoxes
49
86
  ? {
50
87
  ...config.sagaFrontCreatureBoxes,
51
- collectorInfo: config.sagaFrontCreatureBoxes.collectorInfo ??
52
- defaultCollectorInfoBounds,
88
+ collectorInfo: config.sagaFrontCreatureBoxes.collectorInfo ?? collectorInfo,
53
89
  }
54
90
  : undefined,
55
91
  sagaBackCreatureBoxes: config.sagaBackCreatureBoxes
56
92
  ? {
57
93
  ...config.sagaBackCreatureBoxes,
58
- collectorInfo: config.sagaBackCreatureBoxes.collectorInfo ??
59
- defaultCollectorInfoBounds,
94
+ collectorInfo: config.sagaBackCreatureBoxes.collectorInfo ?? collectorInfo,
60
95
  }
61
96
  : undefined,
62
97
  // Saga layouts swap `boxes` for `flipsidePtBoxes` on face 0 when the
@@ -65,7 +100,7 @@ export function withCollectorInfoDefaults(config, defaultCollectorInfoBounds) {
65
100
  flipsidePtBoxes: config.flipsidePtBoxes
66
101
  ? {
67
102
  ...config.flipsidePtBoxes,
68
- collectorInfo: config.flipsidePtBoxes.collectorInfo ?? defaultCollectorInfoBounds,
103
+ collectorInfo: config.flipsidePtBoxes.collectorInfo ?? collectorInfo,
69
104
  }
70
105
  : undefined,
71
106
  };
@@ -0,0 +1,8 @@
1
+ import type { OpacityStop } from "../schema/paint.js";
2
+ /**
3
+ * Opacity stops sampled from `curve`, which maps 0–1 along a gradient to an
4
+ * opacity 0–1 — for writing a gradient's `opacity` from a formula. The stops
5
+ * are spaced evenly, `count` of them (at least two). A canvas draws straight
6
+ * lines between them, so a tight curve wants more.
7
+ */
8
+ export declare function opacityStopsFrom(curve: (t: number) => number, count?: number): OpacityStop[];
@@ -0,0 +1,17 @@
1
+ const round = (value, places) => Number(value.toFixed(places));
2
+ /**
3
+ * Opacity stops sampled from `curve`, which maps 0–1 along a gradient to an
4
+ * opacity 0–1 — for writing a gradient's `opacity` from a formula. The stops
5
+ * are spaced evenly, `count` of them (at least two). A canvas draws straight
6
+ * lines between them, so a tight curve wants more.
7
+ */
8
+ export function opacityStopsFrom(curve, count = 24) {
9
+ const n = Math.max(2, Math.round(count));
10
+ return Array.from({ length: n }, (_, i) => {
11
+ const at = i / (n - 1);
12
+ return {
13
+ at: round(at, 6),
14
+ opacity: round(Math.min(1, Math.max(0, curve(at))), 4),
15
+ };
16
+ });
17
+ }
@@ -16,3 +16,4 @@ export { type FrameServer, loadFrameExport, startFrameServer, } from "./loadFram
16
16
  export { type Problem, Reporter, type Severity, formatProblem, inGitHubActions, } from "./report.js";
17
17
  export { SCHEMA_NAMES, SHIPPED_SCHEMAS, type SchemaName, schemaFilename, toJsonSchema, } from "./schema.js";
18
18
  export { stableStringify } from "./stableJson.js";
19
+ export { type Pixels, type Rect, type TrimOptions, type TrimResult, type TrimmedImage, cropPixels, opaqueBounds, trimImages, unionBounds, } from "./trim.js";
package/dist/cli/index.js CHANGED
@@ -16,3 +16,4 @@ export { loadFrameExport, startFrameServer, } from "./loadFrame.js";
16
16
  export { Reporter, formatProblem, inGitHubActions, } from "./report.js";
17
17
  export { SCHEMA_NAMES, SHIPPED_SCHEMAS, schemaFilename, toJsonSchema, } from "./schema.js";
18
18
  export { stableStringify } from "./stableJson.js";
19
+ export { cropPixels, opaqueBounds, trimImages, unionBounds, } from "./trim.js";
@@ -32,7 +32,7 @@ async function importVite() {
32
32
  * loop would appear to run and do nothing.
33
33
  */
34
34
  export function invalidateFrameModules(frameServer) {
35
- // Both supported Vite majors expose the SSR module graph here; the legacy
35
+ // Every supported Vite major exposes the SSR module graph here; the legacy
36
36
  // top-level accessor is deprecated.
37
37
  frameServer.server.environments.ssr.moduleGraph.invalidateAll();
38
38
  }
package/dist/cli/main.js CHANGED
@@ -9,6 +9,8 @@ import { PackagingError } from "./errors.js";
9
9
  import { Reporter, inGitHubActions } from "./report.js";
10
10
  import { SCHEMA_NAMES, toJsonSchema } from "./schema.js";
11
11
  import { stableStringify } from "./stableJson.js";
12
+ import { runStudio } from "./studio/serve.js";
13
+ import { trimImages } from "./trim.js";
12
14
  import { watchFrames } from "./watch.js";
13
15
  const USAGE = `frame-kit — package Card Anvil frames
14
16
 
@@ -17,6 +19,9 @@ Usage:
17
19
  frame-kit build --out <dir> [options] check every frame, then pack each one
18
20
  frame-kit schema <name> [--out <file>] print a format as JSON Schema
19
21
  (${SCHEMA_NAMES.join(" | ")})
22
+ frame-kit studio [options] open the visual box editor
23
+ frame-kit trim <png...> [options] cut images down to their visible
24
+ pixels, and say where they sit
20
25
  frame-kit --help
21
26
  frame-kit --version
22
27
 
@@ -35,6 +40,27 @@ build only:
35
40
  in the index
36
41
  --watch rebuild a frame whenever its source changes
37
42
 
43
+ trim only:
44
+ --shared cut every image to one rectangle holding all of their
45
+ visible pixels, so they keep one position on the sheet
46
+ --out <dir> write trimmed images here instead of replacing them
47
+ --dry-run say what would be cut, write nothing
48
+
49
+ studio only:
50
+ --port <n> port to serve on (default: 4620)
51
+ --host <h> interface to bind (default: 127.0.0.1)
52
+ --open open the studio in your browser
53
+ --dev serve the studio's own UI from source, with hot
54
+ reloading (only inside the frames workspace)
55
+ --no-write serve read-only; refuse every edit
56
+
57
+ A trimmed image keeps its pixels exactly, and records where it sits on the
58
+ sheet so a second trim still reports that position. Put the position in the
59
+ frame where the field asks for one, such as a Saga's bannerOrigin.
60
+
61
+ The studio edits the literals in your source when you drag a box, so run it on
62
+ a clean working tree. It needs TypeScript installed to find them.
63
+
38
64
  A frame is any directory containing a frame.meta.json. One with "private": true
39
65
  is checked by validate but never built.
40
66
  `;
@@ -161,6 +187,62 @@ async function runBuild(root, only, values) {
161
187
  }
162
188
  return reporter.ok ? EXIT.ok : EXIT.failed;
163
189
  }
190
+ function describeRect(rect) {
191
+ return `${String(rect.width)} × ${String(rect.height)} at (${String(rect.x)}, ${String(rect.y)})`;
192
+ }
193
+ async function runTrim(argv) {
194
+ let parsed;
195
+ try {
196
+ parsed = parseArgs({
197
+ args: argv,
198
+ options: {
199
+ shared: { type: "boolean" },
200
+ out: { type: "string" },
201
+ "dry-run": { type: "boolean" },
202
+ json: { type: "boolean" },
203
+ },
204
+ allowPositionals: true,
205
+ strict: true,
206
+ });
207
+ }
208
+ catch (cause) {
209
+ console.error(`${cause instanceof Error ? cause.message : String(cause)}\n\n${USAGE}`);
210
+ return EXIT.usage;
211
+ }
212
+ const { values, positionals: files } = parsed;
213
+ if (files.length === 0) {
214
+ console.error(`trim needs at least one PNG.\n\n${USAGE}`);
215
+ return EXIT.usage;
216
+ }
217
+ const result = await trimImages(files, {
218
+ shared: values.shared === true,
219
+ ...(values.out !== undefined ? { outDir: values.out } : {}),
220
+ dryRun: values["dry-run"] === true,
221
+ });
222
+ if (values.json) {
223
+ console.log(JSON.stringify(result, null, 2));
224
+ return EXIT.ok;
225
+ }
226
+ for (const image of result.images) {
227
+ const from = `${String(image.width)} × ${String(image.height)}`;
228
+ const what = image.kept
229
+ ? `${from} → ${describeRect(image.kept)}`
230
+ : `${from}, nothing visible, left as it is`;
231
+ const nothingToCut = image.kept?.width === image.width && image.kept.height === image.height;
232
+ const action = image.written
233
+ ? ""
234
+ : nothingToCut
235
+ ? " (already trimmed)"
236
+ : values["dry-run"] && image.kept
237
+ ? " (dry run)"
238
+ : "";
239
+ console.log(`${image.file} ${what}${action}`);
240
+ }
241
+ if (result.shared) {
242
+ console.log(`\nEvery image was cut to ${describeRect(result.shared)} on the sheet.`);
243
+ }
244
+ return EXIT.ok;
245
+ }
164
246
  function isSchemaName(name) {
165
247
  return SCHEMA_NAMES.includes(name);
166
248
  }
@@ -208,6 +290,12 @@ async function main(argv) {
208
290
  if (command === "schema") {
209
291
  return await runSchema(argv.slice(1));
210
292
  }
293
+ if (command === "studio") {
294
+ return await runStudio(argv.slice(1));
295
+ }
296
+ if (command === "trim") {
297
+ return await runTrim(argv.slice(1));
298
+ }
211
299
  if (command !== "validate" && command !== "build") {
212
300
  console.error(`Unknown command "${command}".\n\n${USAGE}`);
213
301
  return EXIT.usage;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * How long to wait for changes to stop before reacting.
3
+ *
4
+ * One save can produce several events — a write, a rename, an attribute
5
+ * change — and a tool upstream may rewrite a whole directory.
6
+ */
7
+ export declare const DEBOUNCE_MS = 200;
8
+ /** Whether a path is build output, editor debris, or inside a skipped dir. */
9
+ export declare function isIgnored(file: string): boolean;
10
+ export interface SourceWatcherOptions {
11
+ /** Absolute path the watch is rooted at. Only used for error messages. */
12
+ root: string;
13
+ /** Directories to watch recursively. Overlapping entries are fine. */
14
+ dirs: readonly string[];
15
+ /**
16
+ * Called with each batch of changed paths, debounced and never overlapping.
17
+ *
18
+ * Anything that arrives while a call is in flight is queued and delivered in
19
+ * the next batch, so a slow handler cannot drop a change.
20
+ */
21
+ onChange: (changed: readonly string[]) => void | Promise<void>;
22
+ /** Ignored on top of the built-in list — a build's own output directory, say. */
23
+ ignore?: (file: string) => boolean;
24
+ /** Defaults to `DEBOUNCE_MS`. */
25
+ debounceMs?: number;
26
+ /** Where an unwatchable directory or a throwing handler is reported. */
27
+ onError?: (message: string) => void;
28
+ }
29
+ export interface SourceWatcher {
30
+ close: () => void;
31
+ }
32
+ /**
33
+ * Watches directories and delivers debounced, non-overlapping change batches.
34
+ *
35
+ * Shared by `frame-kit build --watch` and `frame-kit studio`, which need the
36
+ * same three things and get them wrong in the same three ways otherwise:
37
+ * coalescing a burst of events from one save, never running two handlers at
38
+ * once, and surviving a handler that throws.
39
+ */
40
+ export declare function startSourceWatcher(options: SourceWatcherOptions): SourceWatcher;