@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/dist/cli/watch.js CHANGED
@@ -1,30 +1,9 @@
1
- import { watch } from "node:fs";
2
1
  import path from "node:path";
3
2
  import { buildFrames } from "./build.js";
4
- import { discoverFrames, relativePosix } from "./discover.js";
3
+ import { discoverFrames } from "./discover.js";
5
4
  import { invalidateFrameModules, startFrameServer } from "./loadFrame.js";
6
5
  import { Reporter, formatProblem } from "./report.js";
7
- /** Directories never worth watching. Mirrors discovery's own skip list. */
8
- const SKIP_DIRS = new Set([
9
- "node_modules",
10
- "dist",
11
- ".git",
12
- ".pnpm-store",
13
- "__fixtures__",
14
- ]);
15
- /** Files a rebuild produces, or an editor leaves lying around. */
16
- const IGNORED_FILE = /(\.cardframe|frame-index\.json|\.preview\.[a-z]+|~|\.swp|\.tmp)$/i;
17
- /**
18
- * How long to wait for changes to stop before rebuilding.
19
- *
20
- * One save can produce several events — a write, a rename, an attribute
21
- * change — and a tool upstream may rewrite a whole directory.
22
- */
23
- const DEBOUNCE_MS = 200;
24
- function isIgnored(file) {
25
- const segments = file.split(path.sep);
26
- return (segments.some((segment) => SKIP_DIRS.has(segment) || segment.startsWith(".")) || IGNORED_FILE.test(file));
27
- }
6
+ import { startSourceWatcher } from "./sourceWatcher.js";
28
7
  const plural = (n) => `${String(n)} frame${n === 1 ? "" : "s"}`;
29
8
  /**
30
9
  * Rebuilds frames as their sources change, until the process is interrupted.
@@ -32,6 +11,9 @@ const plural = (n) => `${String(n)} frame${n === 1 ? "" : "s"}`;
32
11
  * One Vite server stays alive for the whole session — starting one costs a
33
12
  * second or two — and its module graph is dropped before each rebuild so an
34
13
  * edit is actually seen. See `invalidateFrameModules`.
14
+ *
15
+ * Debouncing, ignore rules and the non-overlapping drain live in
16
+ * `startSourceWatcher`, shared with `frame-kit studio`.
35
17
  */
36
18
  export async function watchFrames(options) {
37
19
  const root = path.resolve(options.root);
@@ -41,10 +23,7 @@ export async function watchFrames(options) {
41
23
  console.log(summary);
42
24
  });
43
25
  const frameServer = await startFrameServer(root);
44
- const watchers = [];
45
- const pending = new Set();
46
- let timer;
47
- let building = false;
26
+ let watcher;
48
27
  function runBuild(only) {
49
28
  invalidateFrameModules(frameServer);
50
29
  const reporter = new Reporter();
@@ -56,66 +35,23 @@ export async function watchFrames(options) {
56
35
  report(`${plural(frames.length)} rebuilt in ${String(Date.now() - started)}ms`);
57
36
  });
58
37
  }
59
- async function drain() {
60
- if (building) {
61
- // Two builds would race on the same output file. Whatever arrived is
62
- // already in `pending`, and the loop below will pick it up.
63
- return;
64
- }
65
- building = true;
66
- try {
67
- // Keyed off `pending` rather than a flag: anything that arrived during a
68
- // build is still queued when it finishes.
69
- while (pending.size > 0) {
70
- const changed = [...pending];
71
- pending.clear();
72
- const slugs = await slugsFor(root, changed, options.only);
73
- if (slugs?.length === 0) {
74
- continue; // nothing we build was touched
75
- }
76
- await runBuild(slugs);
77
- }
78
- }
79
- catch (cause) {
80
- // A failed rebuild must not end the session: the author is mid-edit and
81
- // the next save is probably the fix.
82
- console.error(cause instanceof Error ? cause.message : String(cause));
83
- }
84
- finally {
85
- building = false;
86
- }
87
- }
88
- function onChange(file) {
89
- if (isIgnored(file) || file.startsWith(outDir)) {
90
- return;
91
- }
92
- pending.add(file);
93
- if (timer) {
94
- clearTimeout(timer);
95
- }
96
- timer = setTimeout(() => void drain(), DEBOUNCE_MS);
97
- }
98
- function watchDir(dir) {
99
- try {
100
- watchers.push(watch(dir, { recursive: true }, (_event, name) => {
101
- if (name !== null) {
102
- onChange(path.join(dir, name));
103
- }
104
- }));
105
- }
106
- catch {
107
- console.error(`Could not watch ${relativePosix(root, dir)}. Recursive watching is ` +
108
- `not supported on this filesystem; rebuild with \`frame-kit build\`.`);
109
- }
110
- }
111
38
  try {
112
39
  await runBuild(undefined);
113
40
  // Each frame's own directory, plus the root, so a frame added or removed
114
41
  // while watching is noticed.
115
- for (const frame of await discoverFrames(root, new Reporter())) {
116
- watchDir(frame.dir);
117
- }
118
- watchDir(root);
42
+ const frames = await discoverFrames(root, new Reporter());
43
+ watcher = startSourceWatcher({
44
+ root,
45
+ dirs: [...frames.map((frame) => frame.dir), root],
46
+ ignore: (file) => file.startsWith(outDir),
47
+ onChange: async (changed) => {
48
+ const slugs = await slugsFor(root, changed, options.only);
49
+ if (slugs?.length === 0) {
50
+ return; // nothing we build was touched
51
+ }
52
+ await runBuild(slugs);
53
+ },
54
+ });
119
55
  report("Watching for changes. Press Ctrl+C to stop.");
120
56
  await new Promise((resolve) => {
121
57
  const stop = () => {
@@ -126,12 +62,7 @@ export async function watchFrames(options) {
126
62
  });
127
63
  }
128
64
  finally {
129
- if (timer) {
130
- clearTimeout(timer);
131
- }
132
- for (const watcher of watchers) {
133
- watcher.close();
134
- }
65
+ watcher?.close();
135
66
  await frameServer.close();
136
67
  }
137
68
  }
@@ -0,0 +1,71 @@
1
+ import type { CrownRenderConfig, NicknameRenderConfig } from "../schema/frame.js";
2
+ /** Where the PT plate sits when a layout does not say. */
3
+ export declare const DEFAULT_PT_IMAGE: {
4
+ readonly x: 2441;
5
+ readonly y: 3847;
6
+ };
7
+ /** The crown's black bar is fixed art at a fixed place. */
8
+ export declare const CROWN_BLACK_BAR: {
9
+ readonly x: 220;
10
+ readonly y: 200;
11
+ readonly width: 2815;
12
+ readonly height: 140;
13
+ };
14
+ /** Horizontal fallback for the nyx insert when the crown's width is unknown. */
15
+ export declare const NYX_INSERT_FALLBACK_X = 611;
16
+ export interface PlacementConfig {
17
+ readonly crownConfig?: CrownRenderConfig | undefined;
18
+ readonly nicknameConfig?: NicknameRenderConfig | undefined;
19
+ readonly ptImage?: {
20
+ readonly x: number;
21
+ readonly y: number;
22
+ } | undefined;
23
+ /** Whether the card being previewed has a nickname; moves the crown. */
24
+ readonly hasNickname?: boolean;
25
+ }
26
+ export interface Placed {
27
+ readonly x: number;
28
+ readonly y: number;
29
+ }
30
+ export type Placement =
31
+ /** Centred on the canvas — full-sheet art. */
32
+ {
33
+ readonly kind: "center";
34
+ }
35
+ /** Drawn at a fixed point on the sheet. */
36
+ | {
37
+ readonly kind: "at";
38
+ readonly at: Placed;
39
+ }
40
+ /**
41
+ * Positioned by the renderer at draw time from measurements nothing static
42
+ * has — ability rows, saga chapters, the flipside plate.
43
+ */
44
+ | {
45
+ readonly kind: "dynamic";
46
+ readonly reason: string;
47
+ };
48
+ /**
49
+ * Where an asset is drawn on the sheet.
50
+ *
51
+ * Most frame art is full-sheet and simply centres, but several families do
52
+ * not: crowns, nicknames and the PT plate are placed from the layout's own
53
+ * render config, and drawing them centred — as a naive compositor does — puts
54
+ * a legendary crown in the middle of the card. The positions here mirror
55
+ * Card Anvil's `konva/nodes/imageNodes.ts`, which is the authority.
56
+ *
57
+ * `assetPath` is the dotted path within a `FrameAssets`, as `walkAssets`
58
+ * reports it: `base.w`, `crown.nickname`, `pt.u`, and so on.
59
+ */
60
+ export declare function placeAsset(assetPath: string, config?: PlacementConfig, image?: {
61
+ readonly width: number;
62
+ readonly height: number;
63
+ }): Placement;
64
+ /** Resolves a placement to concrete coordinates for an image of known size. */
65
+ export declare function placementOrigin(placement: Placement, image: {
66
+ readonly width: number;
67
+ readonly height: number;
68
+ }, canvas: {
69
+ readonly width: number;
70
+ readonly height: number;
71
+ }): Placed | undefined;
@@ -0,0 +1,104 @@
1
+ import { defaultCrownConfig, defaultNicknameConfig } from "../schema/frame.js";
2
+ /** Where the PT plate sits when a layout does not say. */
3
+ export const DEFAULT_PT_IMAGE = { x: 2441, y: 3847 };
4
+ /** The crown's black bar is fixed art at a fixed place. */
5
+ export const CROWN_BLACK_BAR = {
6
+ x: 220,
7
+ y: 200,
8
+ width: 2815,
9
+ height: 140,
10
+ };
11
+ /** Horizontal fallback for the nyx insert when the crown's width is unknown. */
12
+ export const NYX_INSERT_FALLBACK_X = 611;
13
+ /** First segment of a dotted asset path, e.g. `crown` in `crown.nickname`. */
14
+ const familyOf = (assetPath) => assetPath.split(".")[0] ?? assetPath;
15
+ /**
16
+ * Where an asset is drawn on the sheet.
17
+ *
18
+ * Most frame art is full-sheet and simply centres, but several families do
19
+ * not: crowns, nicknames and the PT plate are placed from the layout's own
20
+ * render config, and drawing them centred — as a naive compositor does — puts
21
+ * a legendary crown in the middle of the card. The positions here mirror
22
+ * Card Anvil's `konva/nodes/imageNodes.ts`, which is the authority.
23
+ *
24
+ * `assetPath` is the dotted path within a `FrameAssets`, as `walkAssets`
25
+ * reports it: `base.w`, `crown.nickname`, `pt.u`, and so on.
26
+ */
27
+ export function placeAsset(assetPath, config = {}, image) {
28
+ const family = familyOf(assetPath);
29
+ const leaf = assetPath.split(".").slice(1).join(".");
30
+ switch (family) {
31
+ // Full-sheet art.
32
+ case "base":
33
+ case "land":
34
+ case "nyx":
35
+ case "tall":
36
+ case "creature":
37
+ case "tokenTitle":
38
+ case "extendedOverlay":
39
+ return { kind: "center" };
40
+ case "black":
41
+ return { kind: "at", at: { x: CROWN_BLACK_BAR.x, y: CROWN_BLACK_BAR.y } };
42
+ case "dfcIcon":
43
+ case "dfcIconSet":
44
+ case "dfcIconColorSet":
45
+ return { kind: "at", at: { x: 0, y: 0 } };
46
+ case "crown": {
47
+ const crown = config.crownConfig ?? defaultCrownConfig;
48
+ const nicknamed = config.hasNickname === true;
49
+ const x = (nicknamed ? crown.nicknameCrownX : undefined) ?? crown.x;
50
+ const y = (nicknamed ? crown.nicknameCrownY : undefined) ?? crown.y;
51
+ // The nyx insert sits just inside the crown it decorates.
52
+ if (leaf.startsWith("nyxInsert")) {
53
+ const insetX = crown.nyxInsertX ??
54
+ (image
55
+ ? x + (CROWN_BLACK_BAR.width - image.width) / 2
56
+ : NYX_INSERT_FALLBACK_X);
57
+ const insetY = (nicknamed ? crown.nyxInsertNicknameY : undefined) ??
58
+ crown.nyxInsertY ??
59
+ y + 2;
60
+ return { kind: "at", at: { x: insetX, y: insetY } };
61
+ }
62
+ return { kind: "at", at: { x, y } };
63
+ }
64
+ case "nickname": {
65
+ const nickname = config.nicknameConfig ?? defaultNicknameConfig;
66
+ return { kind: "at", at: { x: nickname.x, y: nickname.y } };
67
+ }
68
+ case "pt": {
69
+ const pt = config.ptImage ?? DEFAULT_PT_IMAGE;
70
+ return { kind: "at", at: { x: pt.x, y: pt.y } };
71
+ }
72
+ case "planeswalker":
73
+ return {
74
+ kind: "dynamic",
75
+ reason: "ability rows are laid out from the card's ability count",
76
+ };
77
+ case "saga":
78
+ return {
79
+ kind: "dynamic",
80
+ reason: "chapter badges are laid out from the card's chapter count",
81
+ };
82
+ case "flipside":
83
+ return {
84
+ kind: "dynamic",
85
+ reason: "positioned from the other face's power/toughness",
86
+ };
87
+ default:
88
+ return { kind: "center" };
89
+ }
90
+ }
91
+ /** Resolves a placement to concrete coordinates for an image of known size. */
92
+ export function placementOrigin(placement, image, canvas) {
93
+ switch (placement.kind) {
94
+ case "center":
95
+ return {
96
+ x: (canvas.width - image.width) / 2,
97
+ y: (canvas.height - image.height) / 2,
98
+ };
99
+ case "at":
100
+ return placement.at;
101
+ default:
102
+ return undefined;
103
+ }
104
+ }
@@ -0,0 +1,27 @@
1
+ import type { BorderState } from "../schema/condition.js";
2
+ import type { LayoutMasks } from "../schema/frameAssets.js";
3
+ /**
4
+ * The mask a border color recolors the ring through: `borderFull` when
5
+ * "Color Entire Border" is on and the layout ships one, else the plain
6
+ * `border`. Undefined when the layout has no ring mask, so no border color
7
+ * can reach it.
8
+ */
9
+ export declare function ringMaskFor(masks: LayoutMasks | undefined, useFullBorder: boolean): string | undefined;
10
+ export interface BorderStateInput {
11
+ /** The border color setting, when one is picked. */
12
+ borderColor: string | null;
13
+ /** The mask set the face is drawn with. */
14
+ masks: LayoutMasks | undefined;
15
+ useNoBorder: boolean;
16
+ useFullBorder: boolean;
17
+ }
18
+ /**
19
+ * The border as it is about to be drawn, which text boxes' `overrides` match
20
+ * on (see `BorderStateSchema`).
21
+ *
22
+ * Only what the masks actually do counts: "No Border" without a `noBorder`
23
+ * mask cuts nothing away, and a border color without a ring mask recolors
24
+ * nothing — so a frame that cannot change its border always reads
25
+ * `"default"`.
26
+ */
27
+ export declare function borderStateFor({ borderColor, masks, useNoBorder, useFullBorder, }: BorderStateInput): BorderState;
@@ -0,0 +1,31 @@
1
+ import { getReadableTextColor, parseNormalizedCssColor } from "./color.js";
2
+ /**
3
+ * The mask a border color recolors the ring through: `borderFull` when
4
+ * "Color Entire Border" is on and the layout ships one, else the plain
5
+ * `border`. Undefined when the layout has no ring mask, so no border color
6
+ * can reach it.
7
+ */
8
+ export function ringMaskFor(masks, useFullBorder) {
9
+ return useFullBorder && masks?.borderFull ? masks.borderFull : masks?.border;
10
+ }
11
+ /**
12
+ * The border as it is about to be drawn, which text boxes' `overrides` match
13
+ * on (see `BorderStateSchema`).
14
+ *
15
+ * Only what the masks actually do counts: "No Border" without a `noBorder`
16
+ * mask cuts nothing away, and a border color without a ring mask recolors
17
+ * nothing — so a frame that cannot change its border always reads
18
+ * `"default"`.
19
+ */
20
+ export function borderStateFor({ borderColor, masks, useNoBorder, useFullBorder, }) {
21
+ if (useNoBorder && masks?.noBorder) {
22
+ return "none";
23
+ }
24
+ const rgb = borderColor && ringMaskFor(masks, useFullBorder)
25
+ ? parseNormalizedCssColor(borderColor)
26
+ : undefined;
27
+ if (!rgb) {
28
+ return "default";
29
+ }
30
+ return getReadableTextColor(rgb) === "black" ? "light" : "dark";
31
+ }
@@ -0,0 +1,28 @@
1
+ import type { BorderState, RenderCondition } from "../schema/condition.js";
2
+ import type { CardBoxes, SecondFaceBoxes } from "../schema/frame.js";
3
+ import type { FrameColor } from "../schema/frameAssets.js";
4
+ /** What text boxes' `overrides` are matched against, for one render. */
5
+ export interface OverrideState {
6
+ /** The border as it is about to be drawn; see `BorderStateSchema`. */
7
+ border: BorderState;
8
+ /** The template settings in effect for the layout, defaults included. */
9
+ settings: Readonly<Record<string, unknown>>;
10
+ /**
11
+ * The card's frame color, when there is a card. A condition on it never
12
+ * matches without one.
13
+ */
14
+ frameColor?: FrameColor;
15
+ }
16
+ /**
17
+ * Whether every condition a `RenderCondition` names holds for one render — a
18
+ * text box override's, a gradient's or a tinted image's.
19
+ */
20
+ export declare function overrideMatches(when: RenderCondition, state: OverrideState): boolean;
21
+ /**
22
+ * A box set as the renderer draws it for one render: each text box with its
23
+ * matching `overrides` merged over it in order, and the list itself dropped.
24
+ * Bare bounds (`art`, `setSymbol`, `ptImage`) pass through untouched.
25
+ */
26
+ export declare function resolveBoxOverrides(boxes: CardBoxes, state: OverrideState): CardBoxes;
27
+ /** {@link resolveBoxOverrides} for the set a second face is drawn from. */
28
+ export declare function resolveSecondFaceBoxOverrides(boxes: SecondFaceBoxes, state: OverrideState): SecondFaceBoxes;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The `CardBoxes` members that are text boxes, and so can carry `overrides`.
3
+ * `boxOverrides.test.ts` fails if the contract gains one this does not list.
4
+ */
5
+ const TEXT_BOX_KEYS = [
6
+ "mana",
7
+ "title",
8
+ "type",
9
+ "rules",
10
+ "abilities",
11
+ "pt",
12
+ "power",
13
+ "toughness",
14
+ "collectorInfo",
15
+ "startingLoyalty",
16
+ "nicknameTitle",
17
+ "keyword",
18
+ "flipsidePt",
19
+ "flipsideType",
20
+ "flipsideManaCost",
21
+ "flipsideRules",
22
+ "insetTitle",
23
+ "insetMana",
24
+ "insetType",
25
+ "insetRules",
26
+ "defense",
27
+ "fuse",
28
+ ];
29
+ /**
30
+ * The same for a second face's set. `boxOverrides.test.ts` holds this list to
31
+ * the contract too.
32
+ */
33
+ const SECOND_FACE_TEXT_BOX_KEYS = [
34
+ "title",
35
+ "mana",
36
+ "type",
37
+ "rules",
38
+ "pt",
39
+ "power",
40
+ "toughness",
41
+ ];
42
+ /**
43
+ * Whether every condition a `RenderCondition` names holds for one render — a
44
+ * text box override's, a gradient's or a tinted image's.
45
+ */
46
+ export function overrideMatches(when, state) {
47
+ if (when.border !== undefined) {
48
+ const borders = typeof when.border === "string" ? [when.border] : when.border;
49
+ if (!borders.includes(state.border)) {
50
+ return false;
51
+ }
52
+ }
53
+ if (when.frameColor !== undefined) {
54
+ const colors = typeof when.frameColor === "string" ? [when.frameColor] : when.frameColor;
55
+ if (state.frameColor === undefined || !colors.includes(state.frameColor)) {
56
+ return false;
57
+ }
58
+ }
59
+ return Object.entries(when.settings ?? {}).every(([key, value]) => state.settings[key] === value);
60
+ }
61
+ function resolveTextBox(box, state) {
62
+ const { overrides = [], ...resolved } = box;
63
+ for (const override of overrides) {
64
+ if (overrideMatches(override.when, state)) {
65
+ Object.assign(resolved, override.style);
66
+ }
67
+ }
68
+ return resolved;
69
+ }
70
+ /**
71
+ * A box set as the renderer draws it for one render: each text box with its
72
+ * matching `overrides` merged over it in order, and the list itself dropped.
73
+ * Bare bounds (`art`, `setSymbol`, `ptImage`) pass through untouched.
74
+ */
75
+ export function resolveBoxOverrides(boxes, state) {
76
+ const resolved = { ...boxes };
77
+ for (const key of TEXT_BOX_KEYS) {
78
+ const box = boxes[key];
79
+ if (box?.overrides) {
80
+ resolved[key] = resolveTextBox(box, state);
81
+ }
82
+ }
83
+ return resolved;
84
+ }
85
+ /** {@link resolveBoxOverrides} for the set a second face is drawn from. */
86
+ export function resolveSecondFaceBoxOverrides(boxes, state) {
87
+ const resolved = { ...boxes };
88
+ for (const key of SECOND_FACE_TEXT_BOX_KEYS) {
89
+ const box = boxes[key];
90
+ if (box?.overrides) {
91
+ resolved[key] = resolveTextBox(box, state);
92
+ }
93
+ }
94
+ return resolved;
95
+ }
@@ -0,0 +1,23 @@
1
+ export interface RgbColor {
2
+ r: number;
3
+ g: number;
4
+ b: number;
5
+ }
6
+ /**
7
+ * Parses a browser-normalized color string — what a canvas 2D context's
8
+ * `fillStyle` getter returns after being assigned a valid CSS color, e.g.
9
+ * `"#ffffff"` or `"rgba(255, 0, 0, 0.5)"`. Hex and `rgb()`/`rgba()` only: a
10
+ * named color has to go through a canvas first.
11
+ */
12
+ export declare function parseNormalizedCssColor(normalized: string): RgbColor | undefined;
13
+ /**
14
+ * WCAG relative luminance of an sRGB color (0 = black, 1 = white).
15
+ * https://www.w3.org/TR/WCAG20/#relativeluminancedef
16
+ */
17
+ export declare function relativeLuminance({ r, g, b }: RgbColor): number;
18
+ /**
19
+ * Picks whichever of black or white text has the higher WCAG contrast ratio
20
+ * against `background` — the standard way to choose readable text color for
21
+ * an arbitrary, user-chosen background (e.g. the border-color setting).
22
+ */
23
+ export declare function getReadableTextColor(background: RgbColor): "black" | "white";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Parses a browser-normalized color string — what a canvas 2D context's
3
+ * `fillStyle` getter returns after being assigned a valid CSS color, e.g.
4
+ * `"#ffffff"` or `"rgba(255, 0, 0, 0.5)"`. Hex and `rgb()`/`rgba()` only: a
5
+ * named color has to go through a canvas first.
6
+ */
7
+ export function parseNormalizedCssColor(normalized) {
8
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(normalized.trim());
9
+ const digits = hex?.[1];
10
+ if (digits) {
11
+ if (digits.length === 3) {
12
+ const [r = "0", g = "0", b = "0"] = digits;
13
+ return {
14
+ r: parseInt(r + r, 16),
15
+ g: parseInt(g + g, 16),
16
+ b: parseInt(b + b, 16),
17
+ };
18
+ }
19
+ return {
20
+ r: parseInt(digits.slice(0, 2), 16),
21
+ g: parseInt(digits.slice(2, 4), 16),
22
+ b: parseInt(digits.slice(4, 6), 16),
23
+ };
24
+ }
25
+ const fn = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i.exec(normalized.trim());
26
+ const [, rStr, gStr, bStr] = fn ?? [];
27
+ if (rStr && gStr && bStr) {
28
+ return { r: Number(rStr), g: Number(gStr), b: Number(bStr) };
29
+ }
30
+ return undefined;
31
+ }
32
+ /**
33
+ * WCAG relative luminance of an sRGB color (0 = black, 1 = white).
34
+ * https://www.w3.org/TR/WCAG20/#relativeluminancedef
35
+ */
36
+ export function relativeLuminance({ r, g, b }) {
37
+ const linear = (channel) => {
38
+ const c = channel / 255;
39
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
40
+ };
41
+ return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
42
+ }
43
+ /**
44
+ * Picks whichever of black or white text has the higher WCAG contrast ratio
45
+ * against `background` — the standard way to choose readable text color for
46
+ * an arbitrary, user-chosen background (e.g. the border-color setting).
47
+ */
48
+ export function getReadableTextColor(background) {
49
+ const l = relativeLuminance(background);
50
+ const contrastWithWhite = 1.05 / (l + 0.05);
51
+ const contrastWithBlack = (l + 0.05) / 0.05;
52
+ return contrastWithWhite >= contrastWithBlack ? "white" : "black";
53
+ }
@@ -0,0 +1,28 @@
1
+ import type { BaseColor, FrameAssets, LayoutMasks } from "../schema/frameAssets.js";
2
+ export interface FrameCutoutInput {
3
+ /** The mask set the face is drawn with. */
4
+ masks: LayoutMasks | undefined;
5
+ frameAssets: FrameAssets;
6
+ useNoBorder: boolean;
7
+ isLegendary: boolean;
8
+ hasNickname: boolean;
9
+ /** The base frame's color, after any fallback. */
10
+ frameColor: BaseColor;
11
+ /** The planeswalker 4+-ability ("tall") base frame is in use. */
12
+ isTallFrame: boolean;
13
+ }
14
+ /**
15
+ * The masks cut out of a face's combined frame — base art plus section
16
+ * overlays — in the order the renderer applies them as `destination-in`, each
17
+ * seeing the result of the one before:
18
+ *
19
+ * - `noBorder`, when "No Border" is on;
20
+ * - `legendary`, on a legendary card;
21
+ * - `extended`, whenever the layout ships it (the extended-art silhouette);
22
+ * - the planeswalker colorless-nickname banner cutout, on a nicknamed
23
+ * colorless card.
24
+ *
25
+ * Crowns, nickname plates and PT boxes are drawn afterwards, over the result,
26
+ * so none of these cut them.
27
+ */
28
+ export declare function frameCutoutMasks(input: FrameCutoutInput): string[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The masks cut out of a face's combined frame — base art plus section
3
+ * overlays — in the order the renderer applies them as `destination-in`, each
4
+ * seeing the result of the one before:
5
+ *
6
+ * - `noBorder`, when "No Border" is on;
7
+ * - `legendary`, on a legendary card;
8
+ * - `extended`, whenever the layout ships it (the extended-art silhouette);
9
+ * - the planeswalker colorless-nickname banner cutout, on a nicknamed
10
+ * colorless card.
11
+ *
12
+ * Crowns, nickname plates and PT boxes are drawn afterwards, over the result,
13
+ * so none of these cut them.
14
+ */
15
+ export function frameCutoutMasks(input) {
16
+ const { masks, frameAssets, useNoBorder, isLegendary, hasNickname, frameColor, isTallFrame, } = input;
17
+ const nicknameMasks = frameAssets.planeswalker?.colorlessNicknameMasks;
18
+ const colorlessNickname = frameColor === "c" && hasNickname
19
+ ? isTallFrame
20
+ ? nicknameMasks?.tall
21
+ : nicknameMasks?.regular
22
+ : undefined;
23
+ return [
24
+ useNoBorder ? masks?.noBorder : undefined,
25
+ isLegendary ? masks?.legendary : undefined,
26
+ masks?.extended,
27
+ colorlessNickname,
28
+ ].filter((url) => Boolean(url));
29
+ }