@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.
- package/README.md +119 -0
- package/dist/authoring/collectorInfo.d.ts +17 -0
- package/dist/authoring/collectorInfo.js +62 -0
- package/dist/authoring/index.d.ts +2 -0
- package/dist/authoring/index.js +2 -0
- package/dist/authoring/layoutDefaults.d.ts +20 -2
- package/dist/authoring/layoutDefaults.js +48 -13
- package/dist/authoring/opacityStops.d.ts +8 -0
- package/dist/authoring/opacityStops.js +17 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/loadFrame.js +1 -1
- package/dist/cli/main.js +88 -0
- package/dist/cli/sourceWatcher.d.ts +40 -0
- package/dist/cli/sourceWatcher.js +100 -0
- package/dist/cli/studio/boxSets.d.ts +33 -0
- package/dist/cli/studio/boxSets.js +90 -0
- package/dist/cli/studio/edit/applyEdits.d.ts +71 -0
- package/dist/cli/studio/edit/applyEdits.js +177 -0
- package/dist/cli/studio/edit/impact.d.ts +53 -0
- package/dist/cli/studio/edit/impact.js +87 -0
- package/dist/cli/studio/edit/resolveModule.d.ts +10 -0
- package/dist/cli/studio/edit/resolveModule.js +55 -0
- package/dist/cli/studio/edit/resolvePath.d.ts +53 -0
- package/dist/cli/studio/edit/resolvePath.js +606 -0
- package/dist/cli/studio/edit/sourceIndex.d.ts +29 -0
- package/dist/cli/studio/edit/sourceIndex.js +73 -0
- package/dist/cli/studio/edit/ts.d.ts +14 -0
- package/dist/cli/studio/edit/ts.js +34 -0
- package/dist/cli/studio/serve.d.ts +23 -0
- package/dist/cli/studio/serve.js +516 -0
- package/dist/cli/studio/session.d.ts +73 -0
- package/dist/cli/studio/session.js +154 -0
- package/dist/cli/trim.d.ts +62 -0
- package/dist/cli/trim.js +251 -0
- package/dist/cli/watch.d.ts +3 -0
- package/dist/cli/watch.js +20 -89
- package/dist/layout/assetPlacement.d.ts +71 -0
- package/dist/layout/assetPlacement.js +104 -0
- package/dist/layout/border.d.ts +27 -0
- package/dist/layout/border.js +31 -0
- package/dist/layout/boxOverrides.d.ts +28 -0
- package/dist/layout/boxOverrides.js +95 -0
- package/dist/layout/color.d.ts +23 -0
- package/dist/layout/color.js +53 -0
- package/dist/layout/cutouts.d.ts +28 -0
- package/dist/layout/cutouts.js +29 -0
- package/dist/layout/decorations.d.ts +73 -0
- package/dist/layout/decorations.js +104 -0
- package/dist/layout/frameLayers.d.ts +163 -0
- package/dist/layout/frameLayers.js +433 -0
- package/dist/layout/index.d.ts +27 -0
- package/dist/layout/index.js +27 -0
- package/dist/layout/paint.d.ts +36 -0
- package/dist/layout/paint.js +148 -0
- package/dist/layout/powerToughnessRow.d.ts +26 -0
- package/dist/layout/powerToughnessRow.js +13 -0
- package/dist/layout/resolveFrameAsset.d.ts +37 -0
- package/dist/layout/resolveFrameAsset.js +66 -0
- package/dist/layout/rulesSlide.d.ts +13 -0
- package/dist/layout/rulesSlide.js +33 -0
- package/dist/layout/settings.d.ts +26 -0
- package/dist/layout/settings.js +62 -0
- package/dist/layout/textStyle.d.ts +29 -0
- package/dist/layout/textStyle.js +28 -0
- package/dist/layout/turn.d.ts +54 -0
- package/dist/layout/turn.js +107 -0
- package/dist/manifest/contract.d.ts +6103 -9063
- package/dist/manifest/contract.js +48 -1
- package/dist/manifest/testFrame.js +9 -0
- package/dist/schema/collectorInfo.d.ts +95 -0
- package/dist/schema/collectorInfo.js +80 -0
- package/dist/schema/condition.d.ts +67 -0
- package/dist/schema/condition.js +47 -0
- package/dist/schema/frame.d.ts +17213 -24450
- package/dist/schema/frame.js +418 -14
- package/dist/schema/frameAssets.d.ts +56 -34
- package/dist/schema/frameAssets.js +96 -2
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.js +3 -0
- package/dist/schema/layout.d.ts +29 -4
- package/dist/schema/layout.js +27 -2
- package/dist/schema/paint.d.ts +202 -0
- package/dist/schema/paint.js +109 -0
- package/dist/studio/assets/PlantinMTProRgIt-D0FB51kb.woff2 +0 -0
- package/dist/studio/assets/beleren-b-B3lFvodX.ttf +0 -0
- package/dist/studio/assets/beleren-bsc-yvL6mU7X.ttf +0 -0
- package/dist/studio/assets/index-BnhNwm_r.js +74 -0
- package/dist/studio/index.html +12 -0
- 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
|
|
3
|
+
import { discoverFrames } from "./discover.js";
|
|
5
4
|
import { invalidateFrameModules, startFrameServer } from "./loadFrame.js";
|
|
6
5
|
import { Reporter, formatProblem } from "./report.js";
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
+
}
|