diff --git a/components/designer/CardFrame.js b/components/designer/CardFrame.js
index f9d9fb8..c2c7f06 100644
--- a/components/designer/CardFrame.js
+++ b/components/designer/CardFrame.js
@@ -14,8 +14,16 @@ export const CARD_H = 588;
* so html-to-image can rasterize it 1:1 during PNG export.
*/
export default function CardFrame({ design, innerRef }) {
+ if (design.art_mode === 'fullart') {
+ return ;
+ }
+ return ;
+}
+
+/* ─────────────────────────── framed layout ─────────────────────────── */
+
+function FramedCard({ design, innerRef }) {
const frame = getFrame(design.frame_id);
- const rarity = getRarity(design.rarity);
const p = frame.palette;
const showPt = Boolean(design.power || design.toughness);
@@ -88,41 +96,21 @@ export default function CardFrame({ design, innerRef }) {
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
-
+ {/* Edge-to-edge artwork */}
+ {hasArt ? (
+

+ ) : (
+
+ )}
+
+ {/* Top scrim: title + cost */}
+
+ {design.name || 'Untitled Card'}
+
+
+
+
+ {/* Bottom scrim: type line, text, P/T */}
+
- {design.rules_text && (
-
+
- {design.rules_text}
-
- )}
- {design.rules_text && design.actions && (
-
- )}
- {design.actions && (
-
- {design.actions}
-
- )}
- {!design.rules_text && !design.actions && (
-
- Description & actions appear here
-
- )}
+ {design.card_type || '— Type —'}
+
+
+
+
+
{showPt && (
+
+
+ Upload artwork
+
+
+ );
+}
+
+function TextBox({ design, p, showPt }) {
+ const hasAny = design.rules_text || design.actions || design.flavor_quote;
+
+ return (
+
+ {hasAny ? (
+
+ ) : (
+
+ Description & actions appear here
+
+ )}
+
+ {showPt && (
+
+ {design.power || '0'} / {design.toughness || '0'}
+
+ )}
+
+ );
+}
+
+/**
+ * Ordered text content: description, actions, then the flavor quotation.
+ * Sections are separated by ornamental dividers; the quote renders in
+ * italics wrapped in decorative quotation marks.
+ */
+function TextBlocks({ design, color, dividerColor, compact = false }) {
+ const fontSize = compact ? 11.5 : 12.5;
+ const blocks = [];
+
+ if (design.rules_text) {
+ blocks.push(
+
+ {design.rules_text}
+
+ );
+ }
+
+ if (design.actions) {
+ if (blocks.length > 0) blocks.push(
);
+ blocks.push(
+
+ {design.actions}
+
+ );
+ }
+
+ if (design.flavor_quote) {
+ if (blocks.length > 0) blocks.push(
);
+ blocks.push(
+
+ “
+ {design.flavor_quote}
+ ”
+
+ );
+ }
+
+ return
{blocks}
;
+}
+
+/** Thin rule; ornament adds a small diamond at center. */
+function Divider({ color, ornament = false }) {
+ if (!ornament) {
+ return
;
+ }
+ return (
+
+ );
+}
+
+/**
+ * Rarity badge rendered inside the type bar: a distinct shape and color
+ * per rarity, vertically centered, right-aligned. Inline SVG keeps PNG
+ * export pixel-faithful.
+ *
+ * common → circle
+ * uncommon → diamond
+ * rare → pentagon
+ * mythic → star
+ */
+export function RarityBadge({ rarityId, size = 18 }) {
+ const rarity = getRarity(rarityId);
+ const s = size;
+ const stroke = 'rgba(0,0,0,0.55)';
+ const shine = 'rgba(255,255,255,0.5)';
+
+ const shapes = {
+ common: (
+
+ ),
+ uncommon: (
+
+ ),
+ rare: (
+
+ ),
+ mythic: (
+
+ ),
+ };
+
+ return (
+
+
+
+ );
+}
+
+function starPath(s) {
+ const cx = s / 2;
+ const cy = s / 2 + s * 0.04;
+ const outer = s / 2 - 0.5;
+ const inner = outer * 0.42;
+ const points = [];
+ for (let i = 0; i < 10; i += 1) {
+ const r = i % 2 === 0 ? outer : inner;
+ const angle = (Math.PI / 5) * i - Math.PI / 2;
+ points.push(`${(cx + r * Math.cos(angle)).toFixed(2)} ${(cy + r * Math.sin(angle)).toFixed(2)}`);
+ }
+ return `M${points.join(' L')} Z`;
+}
+
/**
* Scales CardFrame to fit the available width while preserving the
* natural 420x588 layout (transform keeps export coordinates intact).
diff --git a/lib/custom-cards-fields.js b/lib/custom-cards-fields.js
new file mode 100644
index 0000000..6876982
--- /dev/null
+++ b/lib/custom-cards-fields.js
@@ -0,0 +1,25 @@
+/**
+ * Shared field normalization for custom card designs.
+ * Single source of truth so the create and update routes never drift.
+ */
+
+export function pickDesignFields(body = {}) {
+ const str = (v) => (typeof v === 'string' ? v.trim() : null);
+
+ const artMode = str(body.art_mode) || str(body.artMode) || 'framed';
+
+ return {
+ name: str(body.name),
+ manaCost: str(body.mana_cost) || str(body.manaCost),
+ cardType: str(body.card_type) || str(body.cardType),
+ rarity: str(body.rarity),
+ rulesText: str(body.rules_text) || str(body.description),
+ actions: str(body.actions),
+ flavorQuote: str(body.flavor_quote) || str(body.flavorQuote),
+ power: str(body.power),
+ toughness: str(body.toughness),
+ frameId: str(body.frame_id) || str(body.frameId) || 'classic',
+ artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
+ artMode: artMode === 'fullart' ? 'fullart' : 'framed',
+ };
+}
diff --git a/migrations/1787685911000_add-custom-cards-art-mode-flavor-quote.js b/migrations/1787685911000_add-custom-cards-art-mode-flavor-quote.js
new file mode 100644
index 0000000..aacc716
--- /dev/null
+++ b/migrations/1787685911000_add-custom-cards-art-mode-flavor-quote.js
@@ -0,0 +1,37 @@
+/**
+ * Phase 1 designer upgrades:
+ * - art_mode: 'framed' (classic layout) | 'fullart' (edge-to-edge art,
+ * text on scrims)
+ * - flavor_quote: optional quotation rendered with decorative dividers
+ */
+export const up = (pgm) => {
+ pgm.sql(`
+ ALTER TABLE custom_cards
+ ADD COLUMN IF NOT EXISTS art_mode VARCHAR(20) NOT NULL DEFAULT 'framed',
+ ADD COLUMN IF NOT EXISTS flavor_quote TEXT
+ `);
+
+ pgm.sql(`
+ ALTER TABLE custom_cards
+ DROP CONSTRAINT IF EXISTS chk_custom_cards_art_mode
+ `);
+
+ pgm.sql(`
+ ALTER TABLE custom_cards
+ ADD CONSTRAINT chk_custom_cards_art_mode
+ CHECK (art_mode IN ('framed', 'fullart'))
+ `);
+};
+
+export const down = (pgm) => {
+ pgm.sql(`
+ ALTER TABLE custom_cards
+ DROP CONSTRAINT IF EXISTS chk_custom_cards_art_mode
+ `);
+
+ pgm.sql(`
+ ALTER TABLE custom_cards
+ DROP COLUMN IF EXISTS art_mode,
+ DROP COLUMN IF EXISTS flavor_quote
+ `);
+};
diff --git a/pages/api/custom-cards/[id].js b/pages/api/custom-cards/[id].js
index a161bba..907e79c 100644
--- a/pages/api/custom-cards/[id].js
+++ b/pages/api/custom-cards/[id].js
@@ -1,5 +1,6 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
+import { pickDesignFields } from '../../../lib/custom-cards-fields.js';
import { syncCatalogCard, ensureOwnedRow } from './index.js';
export default async function handler(req, res) {
@@ -28,7 +29,7 @@ export default async function handler(req, res) {
}
if (req.method === 'PUT') {
- const f = pickFields(req.body);
+ const f = pickDesignFields(req.body);
if (!f.name) {
return res.status(400).json({ error: 'Name is required' });
}
@@ -38,8 +39,10 @@ export default async function handler(req, res) {
name = ${f.name}, mana_cost = ${f.manaCost},
card_type = ${f.cardType}, rarity = ${f.rarity},
rules_text = ${f.rulesText}, actions = ${f.actions},
+ flavor_quote = ${f.flavorQuote},
power = ${f.power}, toughness = ${f.toughness},
frame_id = ${f.frameId}, artwork_url = ${f.artworkUrl},
+ art_mode = ${f.artMode},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${designId}
RETURNING *
@@ -73,19 +76,3 @@ export default async function handler(req, res) {
return res.status(500).json({ error: 'Internal server error' });
}
}
-
-function pickFields(body) {
- const str = (v) => (typeof v === 'string' ? v.trim() : null);
- return {
- name: str(body.name),
- manaCost: str(body.mana_cost) || str(body.manaCost),
- cardType: str(body.card_type) || str(body.cardType),
- rarity: str(body.rarity),
- rulesText: str(body.rules_text) || str(body.description),
- actions: str(body.actions),
- power: str(body.power),
- toughness: str(body.toughness),
- frameId: str(body.frame_id) || str(body.frameId) || 'classic',
- artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
- };
-}
diff --git a/pages/api/custom-cards/index.js b/pages/api/custom-cards/index.js
index 4841681..21f1565 100644
--- a/pages/api/custom-cards/index.js
+++ b/pages/api/custom-cards/index.js
@@ -1,5 +1,6 @@
import { sql } from '../../../lib/sql.js';
import { getUserFromRequest } from '../../../lib/permission-middleware';
+import { pickDesignFields } from '../../../lib/custom-cards-fields.js';
const CUSTOM_GAME = 'Custom';
const CUSTOM_SET = 'Designs';
@@ -14,8 +15,8 @@ export default async function handler(req, res) {
if (req.method === 'GET') {
const result = await sql`
SELECT id, card_id, name, mana_cost, card_type, rarity,
- rules_text, actions, power, toughness, frame_id,
- artwork_url, created_at, updated_at
+ rules_text, actions, flavor_quote, power, toughness,
+ frame_id, artwork_url, art_mode, created_at, updated_at
FROM custom_cards
WHERE user_id = ${user.userId}
ORDER BY updated_at DESC
@@ -38,34 +39,19 @@ export default async function handler(req, res) {
}
}
-function pickFields(body) {
- const str = (v) => (typeof v === 'string' ? v.trim() : null);
- return {
- name: str(body.name),
- manaCost: str(body.mana_cost) || str(body.manaCost),
- cardType: str(body.card_type) || str(body.cardType),
- rarity: str(body.rarity),
- rulesText: str(body.rules_text) || str(body.description),
- actions: str(body.actions),
- power: str(body.power),
- toughness: str(body.toughness),
- frameId: str(body.frame_id) || str(body.frameId) || 'classic',
- artworkUrl: str(body.artwork_url) || str(body.artworkUrl),
- };
-}
-
async function createDesign(userId, body) {
- const f = pickFields(body);
+ const f = pickDesignFields(body);
if (!f.name) return null;
const inserted = await sql`
INSERT INTO custom_cards
(user_id, name, mana_cost, card_type, rarity, rules_text,
- actions, power, toughness, frame_id, artwork_url)
+ actions, flavor_quote, power, toughness, frame_id, artwork_url,
+ art_mode)
VALUES
(${userId}, ${f.name}, ${f.manaCost}, ${f.cardType}, ${f.rarity},
- ${f.rulesText}, ${f.actions}, ${f.power}, ${f.toughness},
- ${f.frameId}, ${f.artworkUrl})
+ ${f.rulesText}, ${f.actions}, ${f.flavorQuote}, ${f.power},
+ ${f.toughness}, ${f.frameId}, ${f.artworkUrl}, ${f.artMode})
RETURNING *
`;
const design = inserted.rows[0];
@@ -94,7 +80,10 @@ export async function syncCatalogCard(design, fields) {
game: CUSTOM_GAME,
manaCost: fields.manaCost,
cardType: fields.cardType,
- oracleText: [fields.rulesText, fields.actions].filter(Boolean).join('\n\n') || null,
+ oracleText:
+ [fields.actions, fields.rulesText, fields.flavorQuote]
+ .filter(Boolean)
+ .join('\n\n') || null,
imageUrl: fields.artworkUrl,
};
diff --git a/pages/designer.js b/pages/designer.js
index 45cdc1a..4881f2b 100644
--- a/pages/designer.js
+++ b/pages/designer.js
@@ -15,10 +15,12 @@ const BLANK_DESIGN = {
rarity: 'common',
rules_text: '',
actions: '',
+ flavor_quote: '',
power: '',
toughness: '',
frame_id: 'classic',
artwork_url: '',
+ art_mode: 'framed',
};
export default function Designer() {
@@ -254,6 +256,27 @@ export default function Designer() {
onChange={(e) => handleUpload(e.target.files?.[0])}
/>
+
+ {[
+ { id: 'framed', label: 'Framed' },
+ { id: 'fullart', label: 'Full Art' },
+ ].map((mode) => (
+
+ ))}
+
@@ -329,13 +352,13 @@ export default function Designer() {
-
+
@@ -351,6 +374,17 @@ export default function Designer() {
/>
+
+
+
Power / Toughness (optional)
diff --git a/test/api/custom-cards.test.js b/test/api/custom-cards.test.js
index 3fcb298..9034631 100644
--- a/test/api/custom-cards.test.js
+++ b/test/api/custom-cards.test.js
@@ -106,6 +106,38 @@ describe('POST /api/custom-cards', () => {
// 4 statements: insert design, insert catalog twin, own row, link back
expect(sql).toHaveBeenCalledTimes(4);
});
+
+ it('normalizes camelCase and full-art fields on create', async () => {
+ sql
+ .mockResolvedValueOnce({
+ rows: [{ id: 11, card_id: null, name: 'Void Mirror' }],
+ })
+ .mockResolvedValueOnce({ rows: [{ id: 78 }] })
+ .mockResolvedValueOnce({ rows: [] })
+ .mockResolvedValueOnce({
+ rows: [{ id: 11, card_id: 78, name: 'Void Mirror' }],
+ });
+
+ const res = createRes();
+ await handler(
+ {
+ method: 'POST',
+ body: {
+ name: 'Void Mirror',
+ artMode: 'fullart',
+ flavorQuote: 'What looks back is never the same twice.',
+ },
+ },
+ res
+ );
+
+ expect(res.statusCode).toBe(201);
+ const insertSql = sql.mock.calls[0][0].join('');
+ // art_mode coerced to the allowed set; flavor_quote mapped through
+ expect(insertSql).toContain('art_mode');
+ expect(insertSql).toContain('flavor_quote');
+ expect(sql.mock.calls[0]).toContain('fullart');
+ });
});
describe('/api/custom-cards/[id]', () => {