@cardanvil/frame-kit 0.4.0 → 0.6.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 (46) hide show
  1. package/README.md +45 -0
  2. package/dist/cli/main.js +16 -0
  3. package/dist/cli/sourceWatcher.d.ts +40 -0
  4. package/dist/cli/sourceWatcher.js +100 -0
  5. package/dist/cli/studio/boxSets.d.ts +33 -0
  6. package/dist/cli/studio/boxSets.js +87 -0
  7. package/dist/cli/studio/edit/applyEdits.d.ts +71 -0
  8. package/dist/cli/studio/edit/applyEdits.js +177 -0
  9. package/dist/cli/studio/edit/impact.d.ts +53 -0
  10. package/dist/cli/studio/edit/impact.js +87 -0
  11. package/dist/cli/studio/edit/resolveModule.d.ts +10 -0
  12. package/dist/cli/studio/edit/resolveModule.js +55 -0
  13. package/dist/cli/studio/edit/resolvePath.d.ts +53 -0
  14. package/dist/cli/studio/edit/resolvePath.js +606 -0
  15. package/dist/cli/studio/edit/sourceIndex.d.ts +29 -0
  16. package/dist/cli/studio/edit/sourceIndex.js +73 -0
  17. package/dist/cli/studio/edit/ts.d.ts +14 -0
  18. package/dist/cli/studio/edit/ts.js +34 -0
  19. package/dist/cli/studio/serve.d.ts +23 -0
  20. package/dist/cli/studio/serve.js +513 -0
  21. package/dist/cli/studio/session.d.ts +73 -0
  22. package/dist/cli/studio/session.js +154 -0
  23. package/dist/cli/watch.d.ts +3 -0
  24. package/dist/cli/watch.js +20 -89
  25. package/dist/layout/assetPlacement.d.ts +71 -0
  26. package/dist/layout/assetPlacement.js +104 -0
  27. package/dist/layout/decorations.d.ts +78 -0
  28. package/dist/layout/decorations.js +108 -0
  29. package/dist/layout/frameLayers.d.ts +136 -0
  30. package/dist/layout/frameLayers.js +291 -0
  31. package/dist/layout/index.d.ts +16 -0
  32. package/dist/layout/index.js +16 -0
  33. package/dist/layout/resolveFrameAsset.d.ts +31 -0
  34. package/dist/layout/resolveFrameAsset.js +58 -0
  35. package/dist/manifest/contract.d.ts +671 -1
  36. package/dist/manifest/contract.js +7 -1
  37. package/dist/schema/frame.d.ts +1692 -0
  38. package/dist/schema/frame.js +27 -0
  39. package/dist/schema/frameAssets.d.ts +3 -1
  40. package/dist/schema/frameAssets.js +23 -1
  41. package/dist/studio/assets/PlantinMTProRgIt-D0FB51kb.woff2 +0 -0
  42. package/dist/studio/assets/beleren-b-B3lFvodX.ttf +0 -0
  43. package/dist/studio/assets/beleren-bsc-yvL6mU7X.ttf +0 -0
  44. package/dist/studio/assets/index-CNzyV22G.js +76 -0
  45. package/dist/studio/index.html +12 -0
  46. package/package.json +12 -3
@@ -0,0 +1,73 @@
1
+ import type { Frame } from "../../schema/frame.js";
2
+ import { type DiscoveredFrame } from "../discover.js";
3
+ import { type FrameServer } from "../loadFrame.js";
4
+ import { type Problem } from "../report.js";
5
+ import { type LayoutSlot } from "./boxSets.js";
6
+ import { type SourceIndex } from "./edit/sourceIndex.js";
7
+ import { type TypeScriptApi } from "./edit/ts.js";
8
+ /** One box / asset / mask set present in a frame, addressed by logical path. */
9
+ export interface FrameSlot extends LayoutSlot {
10
+ /** `null` for `config.layouts`, else the `alternateLayouts` key. */
11
+ readonly variant: string | null;
12
+ readonly layout: string;
13
+ readonly path: readonly string[];
14
+ /** Which `CardBoxes` members this set actually defines. */
15
+ readonly boxKeys?: readonly string[];
16
+ }
17
+ export interface FramePayload {
18
+ readonly slug: string;
19
+ readonly id: string;
20
+ readonly name: string;
21
+ /** Bumps on every successful reload, so the client can drop stale state. */
22
+ readonly revision: number;
23
+ readonly canvas: {
24
+ readonly width: number;
25
+ readonly height: number;
26
+ };
27
+ /** The validated frame, with asset URLs rewritten to studio endpoints. */
28
+ readonly frame: unknown;
29
+ readonly slots: readonly FrameSlot[];
30
+ /** Root-relative POSIX paths of every source file the frame was built from. */
31
+ readonly sourceFiles: readonly string[];
32
+ readonly problems: readonly Problem[];
33
+ /** True when this is the last good load and the newest one failed. */
34
+ readonly stale: boolean;
35
+ readonly staleProblems?: readonly Problem[];
36
+ }
37
+ export interface FrameSummary {
38
+ readonly slug: string;
39
+ readonly id: string;
40
+ readonly name: string;
41
+ readonly stale: boolean;
42
+ }
43
+ interface Entry {
44
+ payload: FramePayload;
45
+ /** The frame before URL rewriting — what edits are verified against. */
46
+ raw: Frame;
47
+ discovered: DiscoveredFrame;
48
+ }
49
+ export interface StudioSession {
50
+ readonly root: string;
51
+ readonly index: SourceIndex;
52
+ readonly api: TypeScriptApi;
53
+ readonly frameServer: FrameServer;
54
+ list: () => FrameSummary[];
55
+ get: (slug: string) => Entry | undefined;
56
+ /** Absolute source files worth watching, across every loaded frame. */
57
+ watchDirs: () => string[];
58
+ reload: (only?: readonly string[]) => Promise<string[]>;
59
+ close: () => Promise<void>;
60
+ }
61
+ /**
62
+ * Owns the Vite server, the loaded frames, and the last-good cache.
63
+ *
64
+ * A frame that fails to load keeps its previous payload — an author mid-save
65
+ * produces a syntax error on almost every keystroke, and blanking the canvas
66
+ * each time would make the studio unusable. `loadFrames` already routes every
67
+ * failure into the reporter instead of throwing, so a failed pass simply
68
+ * yields no frame for that slug.
69
+ */
70
+ export declare function startSession(root: string, only?: readonly string[]): Promise<StudioSession>;
71
+ /** Frames discoverable under `root`, for a startup message. */
72
+ export declare function countFrames(root: string): Promise<number>;
73
+ export {};
@@ -0,0 +1,154 @@
1
+ import path from "node:path";
2
+ import { walkAssets } from "../../manifest/walk.js";
3
+ import { setAtPath } from "../../manifest/walk.js";
4
+ import { DEFAULT_CANVAS_HEIGHT, DEFAULT_CANVAS_WIDTH, } from "../../schema/frame.js";
5
+ import { FrameSchema } from "../../schema/frame.js";
6
+ import { loadFrames } from "../api.js";
7
+ import { discoverFrames, relativePosix, } from "../discover.js";
8
+ import { invalidateFrameModules, startFrameServer, } from "../loadFrame.js";
9
+ import { Reporter } from "../report.js";
10
+ import { layoutSlots } from "./boxSets.js";
11
+ import { createSourceIndex } from "./edit/sourceIndex.js";
12
+ import { isUnderRoot, normalizeFile, } from "./edit/sourceIndex.js";
13
+ import { loadTypeScript } from "./edit/ts.js";
14
+ /** Every box/asset/mask set a loaded frame actually defines. */
15
+ function slotsFor(frame) {
16
+ const slots = layoutSlots();
17
+ const found = [];
18
+ const collect = (container, variant, prefix) => {
19
+ for (const [layout, config] of Object.entries(container ?? {})) {
20
+ if (!config || typeof config !== "object") {
21
+ continue;
22
+ }
23
+ const record = config;
24
+ for (const slot of slots) {
25
+ const value = record[slot.key];
26
+ if (!value || typeof value !== "object") {
27
+ continue;
28
+ }
29
+ found.push({
30
+ ...slot,
31
+ variant,
32
+ layout,
33
+ path: [...prefix, layout, slot.key],
34
+ ...(slot.kind === "boxes" ? { boxKeys: Object.keys(value) } : {}),
35
+ });
36
+ }
37
+ }
38
+ };
39
+ const config = frame.config;
40
+ collect(config.layouts, null, ["config", "layouts"]);
41
+ for (const [variant, layouts] of Object.entries(config.alternateLayouts ?? {})) {
42
+ collect(layouts, variant, ["config", "alternateLayouts", variant]);
43
+ }
44
+ return found;
45
+ }
46
+ /**
47
+ * Rewrites every asset URL to the studio's own endpoint.
48
+ *
49
+ * Driven by `walkAssets` rather than a field list, so an asset field added to
50
+ * the contract is served without touching this.
51
+ */
52
+ function rewriteAssets(frame) {
53
+ const plain = JSON.parse(JSON.stringify(frame));
54
+ for (const occurrence of walkAssets(FrameSchema, plain)) {
55
+ setAtPath(plain, occurrence.path, `/api/asset?u=${encodeURIComponent(occurrence.url)}`);
56
+ }
57
+ return plain;
58
+ }
59
+ /**
60
+ * Owns the Vite server, the loaded frames, and the last-good cache.
61
+ *
62
+ * A frame that fails to load keeps its previous payload — an author mid-save
63
+ * produces a syntax error on almost every keystroke, and blanking the canvas
64
+ * each time would make the studio unusable. `loadFrames` already routes every
65
+ * failure into the reporter instead of throwing, so a failed pass simply
66
+ * yields no frame for that slug.
67
+ */
68
+ export async function startSession(root, only = []) {
69
+ const resolvedRoot = path.resolve(root);
70
+ const frameServer = await startFrameServer(resolvedRoot);
71
+ const api = await loadTypeScript();
72
+ const index = createSourceIndex(api);
73
+ const cache = new Map();
74
+ let revision = 0;
75
+ async function reload(slugs) {
76
+ invalidateFrameModules(frameServer);
77
+ const reporter = new Reporter();
78
+ const wanted = slugs ?? only;
79
+ const loaded = await loadFrames({ root: resolvedRoot, ...(wanted.length > 0 ? { only: wanted } : {}) }, reporter, frameServer);
80
+ const seen = new Set();
81
+ const sourceFiles = [
82
+ ...frameServer.server.environments.ssr.moduleGraph.fileToModulesMap.keys(),
83
+ ]
84
+ .filter((file) => isUnderRoot(file, resolvedRoot) && /\.[cm]?tsx?$/.test(file))
85
+ .map((file) => relativePosix(resolvedRoot, file))
86
+ .sort();
87
+ for (const item of loaded) {
88
+ const { discovered, frame } = item;
89
+ seen.add(discovered.slug);
90
+ revision += 1;
91
+ const canvas = frame.config.canvas ?? {
92
+ width: DEFAULT_CANVAS_WIDTH,
93
+ height: DEFAULT_CANVAS_HEIGHT,
94
+ };
95
+ cache.set(discovered.slug, {
96
+ raw: frame,
97
+ discovered,
98
+ payload: {
99
+ slug: discovered.slug,
100
+ id: discovered.meta.id,
101
+ name: frame.name,
102
+ revision,
103
+ canvas,
104
+ frame: rewriteAssets(frame),
105
+ slots: slotsFor(frame),
106
+ sourceFiles,
107
+ problems: reporter.problems.filter((problem) => problem.frame === discovered.slug),
108
+ stale: false,
109
+ },
110
+ });
111
+ }
112
+ // Anything asked for that did not come back kept its last good payload.
113
+ for (const [slug, entry] of cache) {
114
+ if (seen.has(slug) || (wanted.length > 0 && !wanted.includes(slug))) {
115
+ continue;
116
+ }
117
+ const problems = reporter.problems.filter((problem) => problem.frame === slug || problem.frame === undefined);
118
+ cache.set(slug, {
119
+ ...entry,
120
+ payload: { ...entry.payload, stale: true, staleProblems: problems },
121
+ });
122
+ }
123
+ return [...seen];
124
+ }
125
+ await reload();
126
+ return {
127
+ root: resolvedRoot,
128
+ index,
129
+ api,
130
+ frameServer,
131
+ list: () => [...cache.values()]
132
+ .map((entry) => ({
133
+ slug: entry.payload.slug,
134
+ id: entry.payload.id,
135
+ name: entry.payload.name,
136
+ stale: entry.payload.stale,
137
+ }))
138
+ .sort((a, b) => a.slug.localeCompare(b.slug)),
139
+ get: (slug) => cache.get(slug),
140
+ watchDirs: () => {
141
+ const dirs = new Set([resolvedRoot]);
142
+ for (const entry of cache.values()) {
143
+ dirs.add(normalizeFile(entry.discovered.dir));
144
+ }
145
+ return [...dirs];
146
+ },
147
+ reload,
148
+ close: () => frameServer.close(),
149
+ };
150
+ }
151
+ /** Frames discoverable under `root`, for a startup message. */
152
+ export async function countFrames(root) {
153
+ return (await discoverFrames(path.resolve(root), new Reporter())).length;
154
+ }
@@ -9,6 +9,9 @@ export interface WatchOptions extends BuildOptions {
9
9
  * One Vite server stays alive for the whole session — starting one costs a
10
10
  * second or two — and its module graph is dropped before each rebuild so an
11
11
  * edit is actually seen. See `invalidateFrameModules`.
12
+ *
13
+ * Debouncing, ignore rules and the non-overlapping drain live in
14
+ * `startSourceWatcher`, shared with `frame-kit studio`.
12
15
  */
13
16
  export declare function watchFrames(options: WatchOptions): Promise<void>;
14
17
  /**
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,78 @@
1
+ import type { FrameAssets, FrameColor } from "../schema/frameAssets.js";
2
+ import { type FrameDetails } from "./frameLayers.js";
3
+ /**
4
+ * The type-line facts the decoration colours turn on.
5
+ *
6
+ * A `CardShape` satisfies this structurally, and so does a card the app has
7
+ * already read a type line from.
8
+ */
9
+ export interface DecorationFacts {
10
+ readonly isLand?: boolean;
11
+ readonly isArtifact?: boolean;
12
+ readonly isVehicle?: boolean;
13
+ }
14
+ /** One colour, or the two halves of a two-colour banner. */
15
+ export type BannerColors = FrameColor | [FrameColor, FrameColor];
16
+ /**
17
+ * The colour a card's banners take.
18
+ *
19
+ * Driven by the resolved identity rather than the background, so a crown, a
20
+ * nickname plate and a PT box on the same card always agree with each other
21
+ * and with the pinlines.
22
+ */
23
+ export declare function identityColors(details: FrameDetails, facts?: DecorationFacts): BannerColors;
24
+ /** Legendary crown colour(s). */
25
+ export declare function crownColors(details: FrameDetails, facts?: DecorationFacts): BannerColors;
26
+ /**
27
+ * Nickname banner colour(s).
28
+ *
29
+ * A land with no resolved identity uses the dedicated land nickname art rather
30
+ * than the colourless one.
31
+ */
32
+ export declare function nicknameColors(details: FrameDetails, facts?: DecorationFacts): BannerColors;
33
+ /**
34
+ * PT box colour.
35
+ *
36
+ * Only a true two-colour hybrid takes the colourless box. A card built from
37
+ * monocoloured-hybrid pips in three or more colours is an ordinary gold card
38
+ * and must resolve by identity length instead.
39
+ */
40
+ export declare function ptBoxColor(details: FrameDetails, facts?: DecorationFacts): FrameColor;
41
+ /** Combined crown + nickname banner; the UB variant falls back to the plain one. */
42
+ export declare function resolveCrownNicknameAsset(assets: FrameAssets, color: FrameColor, useUBCrowns: boolean): string | undefined;
43
+ /**
44
+ * Legendary crown art.
45
+ *
46
+ * A nicknamed card prefers the combined crown+nickname asset, and falls back
47
+ * to the plain crown when a frame does not ship one.
48
+ */
49
+ export declare function resolveCrownAsset(assets: FrameAssets, color: FrameColor, opts: {
50
+ nickname: boolean;
51
+ useUBCrowns: boolean;
52
+ }): string | undefined;
53
+ export declare function resolveNicknameAsset(assets: FrameAssets, color: FrameColor): string | undefined;
54
+ export declare function resolvePtBoxAsset(assets: FrameAssets, color: FrameColor): string | undefined;
55
+ export declare function resolveNyxInsertAsset(assets: FrameAssets, color: FrameColor, useUBCrowns?: boolean): string | undefined;
56
+ /** What a card is, for deciding which banners it gets. */
57
+ export interface DecorationCard {
58
+ readonly isLegendary?: boolean;
59
+ /** The nickname text; a card has a nickname when this is non-empty. */
60
+ readonly nickname?: string;
61
+ readonly power?: string;
62
+ readonly toughness?: string;
63
+ }
64
+ export interface DecorationState {
65
+ readonly crown: boolean;
66
+ /** The bar behind the crown, drawn on the same condition. */
67
+ readonly crownBlackBar: boolean;
68
+ readonly nickname: boolean;
69
+ readonly ptBox: boolean;
70
+ }
71
+ /**
72
+ * Which banners a card draws.
73
+ *
74
+ * Mirrors the renderer's conditions: crowns are legendary-only, the nickname
75
+ * plate needs nickname text, and the PT box needs *both* a power and a
76
+ * toughness — a card with only one is not a creature and gets no plate.
77
+ */
78
+ export declare function decorationsFor(card: DecorationCard): DecorationState;