diff --git a/components/designer/CardFrame.js b/components/designer/CardFrame.js
index ef43af5..bb811ee 100644
--- a/components/designer/CardFrame.js
+++ b/components/designer/CardFrame.js
@@ -8,6 +8,21 @@ import { getFrame, getRarity } from './frames';
export const CARD_W = 420;
export const CARD_H = 588;
+/**
+ * Default window zones for image-based frames, as fractions of the card.
+ * They mirror the framed layout's art window and text box positions.
+ */
+export const DEFAULT_LAYOUT = {
+ art: { x: 16 / CARD_W, y: 64 / CARD_H, w: 388 / CARD_W, h: 234 / CARD_H },
+ text: { x: 16 / CARD_W, y: 342 / CARD_H, w: 388 / CARD_W, h: 224 / CARD_H },
+};
+
+/** Standard vertical anchors (px) for text rows on image frames. */
+export const IMAGE_FRAME_ROWS = {
+ title: { top: 22, height: 34 },
+ type: { top: 306, height: 28 },
+};
+
/** Resolve the active palette: a custom frame's palette when linked,
* otherwise the starter frame's. */
export function resolvePalette(design) {
@@ -22,6 +37,13 @@ function resolveTexture(design) {
return design.custom_frame?.texture_url || null;
}
+/** Window zones for image frames: saved layout or the defaults. */
+export function resolveLayout(design) {
+ const layout = design.custom_frame?.layout;
+ if (layout?.art && layout?.text) return layout;
+ return DEFAULT_LAYOUT;
+}
+
/**
* Live card renderer for the designer. Renders at CARD_W x CARD_H and is
* scaled to fit its container by CardPreview below. Fully inline-styled
@@ -31,9 +53,186 @@ export default function CardFrame({ design, innerRef, symbols }) {
if (design.art_mode === 'fullart') {
return
- Save the frame first to add a background texture. -
- )} +{frameEditor.id && ( ++ Save the frame first to add a background texture or full frame image. +
+ )} + {frameEditor.frame_image_url && ( ++ Window Editor +
+@@ -789,6 +908,8 @@ export default function Designer() { custom_frame: { palette: frameEditor.palette, texture_url: frameEditor.texture_url, + frame_image_url: frameEditor.frame_image_url, + layout: frameEditor.layout, }, }} symbols={symbolsMap} diff --git a/test/api/custom-frames.test.js b/test/api/custom-frames.test.js index a33d7a1..d833b3d 100644 --- a/test/api/custom-frames.test.js +++ b/test/api/custom-frames.test.js @@ -9,7 +9,7 @@ import { sql } from '../../lib/sql.js'; import { getUserFromRequest } from '../../lib/permission-middleware'; import handler from '../../pages/api/custom-frames/index.js'; import itemHandler from '../../pages/api/custom-frames/[id].js'; -import { validatePalette, PALETTE_SLOTS } from '../../lib/frame-palette.js'; +import { validatePalette, PALETTE_SLOTS, validateLayout } from '../../lib/frame-palette.js'; function createRes() { const res = { @@ -31,6 +31,11 @@ const GOOD_PALETTE = Object.fromEntries( PALETTE_SLOTS.map(({ key }) => [key, '#123456']) ); +const DEFAULT_LAYOUT = { + art: { x: 16 / 420, y: 64 / 588, w: 388 / 420, h: 234 / 588 }, + text: { x: 16 / 420, y: 342 / 588, w: 388 / 420, h: 224 / 588 }, +}; + describe('validatePalette', () => { it('accepts and normalizes a complete palette', () => { const { palette, error } = validatePalette( @@ -49,6 +54,25 @@ describe('validatePalette', () => { }); }); +describe('validateLayout', () => { + it('accepts and normalizes a valid layout', () => { + const { layout, error } = validateLayout({ + art: { x: 0.038, y: 0.109, w: 0.924, h: 0.398 }, + text: { x: 0.038, y: 0.582, w: 0.924, h: 0.381 }, + }); + expect(error).toBeUndefined(); + expect(layout.art.x).toBeCloseTo(0.038); + }); + + it('rejects missing zones', () => { + expect(validateLayout({ art: { x: 0, y: 0, w: 1, h: 1 } }).error).toBeTruthy(); + }); + + it('rejects zones extending past the card', () => { + expect(validateLayout({ art: { x: 0.9, y: 0, w: 0.2, h: 0.5 }, text: { x: 0, y: 0, w: 1, h: 1 } }).error).toBeTruthy(); + }); +}); + describe('/api/custom-frames', () => { beforeEach(() => { vi.clearAllMocks(); @@ -79,6 +103,18 @@ describe('/api/custom-frames', () => { expect(res.body.error).toContain('Invalid or missing color'); }); + it('rejects invalid layouts on create', async () => { + const res = createRes(); + + await handler( + { method: 'POST', body: { name: 'My Frame', palette: GOOD_PALETTE, layout: { art: { x: 1, y: 0, w: 1, h: 1 } } } }, + res + ); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain('extends past the card'); + }); + it('rejects duplicate frame names', async () => { sql.mockResolvedValueOnce({ rows: [{ id: 4 }] }); // clash lookup hits const res = createRes(); @@ -91,11 +127,11 @@ describe('/api/custom-frames', () => { expect(res.statusCode).toBe(409); }); - it('creates a frame with the normalized palette', async () => { + it('creates a frame with the normalized palette and default layout', async () => { sql .mockResolvedValueOnce({ rows: [] }) // clash lookup misses .mockResolvedValueOnce({ - rows: [{ id: 4, name: 'Molten', palette: GOOD_PALETTE }], + rows: [{ id: 4, name: 'Molten', palette: GOOD_PALETTE, layout: { art: { x: 16/420, y: 64/588, w: 388/420, h: 234/588 }, text: { x: 16/420, y: 342/588, w: 388/420, h: 224/588 } } }], }); const res = createRes(); @@ -106,6 +142,7 @@ describe('/api/custom-frames', () => { expect(res.statusCode).toBe(201); expect(res.body.frame.name).toBe('Molten'); + expect(res.body.frame.layout).toBeDefined(); }); }); @@ -126,17 +163,17 @@ describe('/api/custom-frames/[id]', () => { expect(res.statusCode).toBe(404); }); - it('updates name and palette', async () => { + it('updates name, palette, and layout', async () => { sql .mockResolvedValueOnce({ rows: [{ id: 4 }] }) // ownership .mockResolvedValueOnce({ rows: [] }) // clash lookup misses .mockResolvedValueOnce({ - rows: [{ id: 4, name: 'Molten II', palette: GOOD_PALETTE }], + rows: [{ id: 4, name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 16/420, y: 64/588, w: 388/420, h: 234/588 }, text: { x: 16/420, y: 342/588, w: 388/420, h: 224/588 } } }], }); const res = createRes(); await itemHandler( - { method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE } }, + { method: 'PUT', query: { id: '4' }, body: { name: 'Molten II', palette: GOOD_PALETTE, layout: { art: { x: 0.038, y: 0.109, w: 0.924, h: 0.398 }, text: { x: 0.038, y: 0.582, w: 0.924, h: 0.381 } } } }, res ); diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 9e26dfe..0000000 --- a/vercel.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file