Canvas Cards
Design rank, welcome, and other image cards with a drag-and-drop editor backed by one shared renderer and a gradient colour resolver.
Canvas cards (rank cards, welcome cards, and any other rendered image) are described by a plain JSON layout document instead of hardcoded drawing code. One renderer draws that document in two places: the bot renders the final PNG for Discord with medusa.canvas, and the dashboard renders the exact same pixels in a live WYSIWYG editor. Because both sides import the same renderer, the preview always matches what members receive.
Layout Document
A layout has a size, a background, and an ordered list of layers. Layers are drawn back to front.
{
"width": 934,
"height": 282,
"radius": 24,
"font": "Inter, sans-serif",
"background": {
"color": "#13151b",
"url": "",
"bind": "background",
"fit": "cover",
"overlay": "rgba(15, 17, 23, 0.85)"
},
"layers": [
{ "id": "avatar", "type": "avatar", "x": 50, "y": 46, "w": 190, "h": 190, "bind": "avatar", "ring": { "accent": 0.8 }, "ringWidth": 4 },
{ "id": "name", "type": "text", "x": 270, "y": 60, "w": 360, "h": 40, "content": "%displayName%", "size": 32, "weight": "bold", "color": "#ffffff" },
{ "id": "bar", "type": "progress", "x": 270, "y": 160, "w": 614, "h": 32, "radius": 16, "bind": "progress", "track": "rgba(255, 255, 255, 0.08)", "fill": "" }
]
}Layer Types
Every layer has id, type, a box (x, y, w, h), and optional rotation, opacity (0-1), and visible.
| Type | Purpose | Key properties |
|---|---|---|
"text" | A line of text with placeholder substitution | content, size, weight, align, valign, color |
"stat" | A label + value pair (e.g. RANK #1) | label, value, size, align, arrangement, valueColor, labelColor |
"avatar" | A circular avatar with a ring | bind, ring, ringWidth, ringGap |
"image" | An image drawn into the box | url or bind, fit, radius |
"rect" | A rounded rectangle, filled and/or stroked | fill, stroke, strokeWidth, radius |
"progress" | A rounded progress bar | bind, track, fill, radius |
"status" | A Discord presence dot | bind, ring, ringGap |
"icon" | A stroked Lucide icon | icon, color |
Data Bindings
Runtime values reach a layout through placeholders. Text and stat layers substitute %placeholder% inside content, label, and value. Image, avatar, progress, and status layers read a single placeholder named by their bind property (falling back to the static url, percent, or status on the layer). The rank card provides displayName, username, avatar, status, messages, voice, level, rank, currentXp, neededXp, and progress.
Icons
Use an "icon" layer for crisp vector marks instead of unicode emoji, which render inconsistently on the canvas. Set icon to one of the built-in Lucide names and color to any colour value (empty uses the accent). The renderer draws the icon as an inline stroked SVG scaled into the layer box, so it stays sharp at any size on both the bot and the dashboard preview. Available names live in icons.js next to the renderer: coins, flame, star, heart, crown, trophy, zap, gem, sparkles, shield, target, dollar-sign, clock, users, coffee. Add more by pasting a Lucide icon's path data into that map.
{ "id": "coin", "type": "icon", "x": 422, "y": 131, "w": 26, "h": 26, "icon": "coins", "color": "#facc15" }Colour Values and Gradients
Anywhere a colour is expected (color, fill, stroke, track, ring, background.color, background.overlay, stat colours) you may pass any of:
| Form | Result |
|---|---|
"#22c55e", "rgba(255,255,255,0.08)" | A literal CSS colour. |
"" or omitted | The bot accent colour. |
{ "accent": 0.35 } | The bot accent at the given alpha, so cards stay on-brand. |
{ "type": "linear", "angle": 0, "stops": [ ... ] } | A linear gradient across the layer, angle in degrees. |
{ "type": "radial", "stops": [ ... ] } | A radial gradient centred on the layer. |
Each gradient stop is { "offset": 0..1, "color": <colourValue> }, and a stop colour may itself be "" (accent) or { "accent": <alpha> }. This is how a progress bar becomes a gradient:
{ "id": "bar", "type": "progress", "x": 270, "y": 160, "w": 614, "h": 32, "radius": 16, "bind": "progress",
"fill": { "type": "linear", "angle": 0, "stops": [ { "offset": 0, "color": "#22c55e" }, { "offset": 1, "color": "#3b82f6" } ] } }Adding a Card Editor to a Module
The framework ships no card designs. Each module owns its own default layout and the placeholders the card can bind, and both reach the dashboard through the configuration schema, so a module can be added or removed without leaving anything behind in the framework or the dashboard.
Author the default layout and its placeholders in a resources/cards/<name>.js file. Give the layout a stable id; the editor and the bot use it to tell a saved layout for this card apart from one that belongs to a different card.
export const rankCard = {
layout: {
id: "rank",
width: 934,
height: 282,
radius: 24,
background: { color: "#13151b", bind: "background", fit: "cover", overlay: "rgba(15, 17, 23, 0.85)" },
layers: [
{ id: "avatar", type: "avatar", x: 50, y: 46, w: 190, h: 190, bind: "avatar", ring: { accent: 0.8 } },
{ id: "name", type: "text", x: 270, y: 60, w: 360, h: 40, content: "%displayName%", size: 32, weight: "bold", color: "#ffffff" },
{ id: "progress", type: "progress", x: 270, y: 160, w: 614, h: 32, radius: 16, bind: "progress", fill: "" }
]
},
placeholders: [
{ key: "displayName", label: "Display Name", sample: "Nolan" },
{ key: "avatar", label: "Avatar", sample: "" },
{ key: "progress", label: "Progress", sample: 74 }
]
};Then declare a canvas field whose default is that layout and whose placeholders are that placeholder list. The dashboard renders the drag-and-drop editor in place of a normal input, seeds it from default, and drives the preview, placeholder chips, and binding menus from placeholders.
import { Configs } from "#medusa/modules";
import { rankCard } from "../cards/rank.js";
export class CardConfig extends Configs {
constructor(medusa) {
super(medusa, { name: "card", label: "Rank Card", description: "Design the rank card with a drag and drop canvas editor." });
}
schema() {
return {
layout: { type: "canvas", default: rankCard.layout, placeholders: rankCard.placeholders, label: "Rank Card Design" }
};
}
}Each placeholder is { key, label, sample }: key is what %key% and layer binds resolve to at render time, label names it in the editor, and sample is the value the live preview shows. The editor overrides the avatar, displayName, name, and username samples with the signed-in previewer when those placeholders exist.
canvas field is its own live preview, so leave preview off the section. The unsaved value is the module's default layout, so the editor and the bot both start from the module's design until an administrator saves a custom one. A saved layout whose id does not match the field's default is ignored and the default is used, which keeps a card that was saved against a different design from rendering.Rendering on the Bot (medusa.canvas)
The framework exposes medusa.canvas. Load the stored layout, fall back to the module's own default when it is missing or belongs to another card, build a data object from your records, and render; image URLs referenced by bindings are fetched for you.
import { rankCard } from "../../resources/cards/rank.js";
layout() {
const stored = this.medusa.modules.config.value("example", "card", "layout");
if (stored && Array.isArray(stored.layers) && stored.layers.length > 0 && stored.id === rankCard.layout.id) return stored;
return structuredClone(rankCard.layout);
}
async render(member, progress) {
const data = { accent: "#22c55e", displayName: member.displayName, avatar: member.user.displayAvatarURL({ extension: "png", size: 512 }), progress };
return this.medusa.canvas.render(this.layout(), data);
}| Method | Description |
|---|---|
render(layout, data, options?) | Renders a layout document to a PNG Buffer. data.accent seeds every accent-relative colour, and options.scale overrides the default 2x supersampling. Identical renders are served from the canvas.renders cache for 5 minutes. |
image(url) | Loads a remote or data: image for drawing, or null when it cannot be loaded. Loaded images are kept in the canvas.images cache for 30 minutes, so hand-drawn canvases should use it instead of calling loadImage directly. |
The renderer itself (resolveColor, renderLayout, bind, imageUrls) lives in src/medusa/handlers/discord/canvas/layout.js, uses only the shared 2D canvas API, carries no module-specific designs, and the dashboard imports the same file through its @/components/canvas/* alias, so the editor and the bot draw identically.