# Canvas Cards

URL: https://zerodev.ca/docs/medusa/developer-documentation/canvas

> 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.

```json
{
    "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.

```json
{ "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:

```json
{ "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.

```js title="modules/example/resources/cards/rank.js"
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`.

```js title="modules/example/resources/config/card.js"
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 `bind`s 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.

<Callout>
  A 

  `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.
</Callout>

## 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.

```js title="modules/example/src/handlers/canvas.js"
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.
