Skip to content
GitHub

Theming

MeoCord 4.2 · Structuring your app · page 21 of 41 · since 4.1.0

Give every answer your bot sends its colours and emojis by role, set once and changed where you need to.

You'll learn

  • Set a theme for the app and change it for a controller or a handler
  • Read the theme in handlers, services and presenters
  • Look up a theme per server and per user
  • Add tokens of your own and test a themed bot

A theme holds the colours, emojis and button styles your bot's answers use, named by what they mean rather than by their value. Code asks for danger, not for red, so a bot changes its look in one place. MeoCord gives every role a default, so a theme sets only what it changes.

When to use it

Use a theme when your answers share a look: a brand colour on every embed, a success emoji on every confirmation, or a different colour for each server that hosts your bot. Reading a colour from the theme keeps that look in one place instead of in every handler.

You don't need one to start. With no theme, respond() and MeoCord's own views use the defaults, which are tuned to stay readable on every Discord theme. For a one-off embed whose colour means nothing, set the colour on the embed itself.

Example

The app sets its theme, and a controller changes part of it for its own handlers:

app-with-theme.ts
@MeoCord({
  controllers: [StoreSlashController, VoteSlashController],
  clientOptions: { intents: [GatewayIntentBits.Guilds] },
  // Only what changes: every other role keeps MeoCord's default
  theme: { colors: { primary: '#5865F2' }, emojis: { success: '🎉' } },
})
export default class App {}
controllers/slash/store.slash.controller.ts
@Controller()
@UseTheme({ colors: { primary: '#26A042' } })
export class StoreSlashController {
  @Command('receipt', CommandType.SLASH)
  async receipt(interaction: ChatInputCommandInteraction) {
    const { colors, emojis } = useTheme()
    const receipt = new EmbedBuilder().setDescription(`${emojis.success} Paid`).setColor(colors.success)
    await respond(interaction).send({ embeds: [receipt] })
  }

  @Command('refund', CommandType.SLASH)
  @UseTheme({ colors: { primary: '#E3606D' }, emojis: { loading: '💸' } })
  @Defer()
  async refund(interaction: ChatInputCommandInteraction) {
    // No colour set, so respond() gives the embed this handler's primary, #E3606D
    await respond(interaction).send({ embeds: [new EmbedBuilder().setDescription('Refunded')] })
  }

  @Command('banner', CommandType.SLASH)
  async banner(interaction: ChatInputCommandInteraction) {
    // Sent as written: Discord's own stripe, whatever the theme
    const banner = new EmbedBuilder().setImage('https://meocord.dev/og.png')
    await respond(interaction).send({ embeds: [banner] }, { fill: false })
  }
}
Dispatches /receipt; /refund; /banner

/receipt reads the theme and builds its embed from the success role, with the app's 🎉. /refund changes the primary colour and the loading emoji for that one handler, and its embed, sent with no colour, takes that primary. /banner sends its embed as written, whatever the theme.

How it works

A theme is resolved for each call before anything runs, and it stays the same for the whole call. Each layer sets only what it changes, over the one beneath it:

  1. MeoCord's defaults;
  2. @MeoCord({ theme }), the app's theme;
  3. @UseTheme on each class, from a base class down to the controller, as far up as @Controller({ inheritStages }) lets the controller inherit;
  4. @UseTheme on the handler;
  5. the server's theme, then the user's, from themeFor.

Plain objects merge key by key. Anything else, such as an array of colours, replaces the value beneath it. The resolved theme is frozen, since it's shared by every call it applies to.

A bot that sets no @UseTheme and no themeFor builds its theme once, at startup, and each call reads it.

Tokens

A theme has three groups of roles:

GroupRolesA value is
colorsprimary, neutral, success, warning, danger, infoa colour discord.js accepts: '#7680F4', 0x7680f4, [118, 128, 244] or a colour name
emojisloading, success, warning, danger, infoa unicode emoji, or a custom one written <:name:id>, or <a:name:id> when animated
buttonsprimary, neutral, success, dangerButtonStyle.Primary, Secondary, Success or Danger

warning is for what the user can fix, such as a refused or invalid call. danger is a fault in the bot. MeoCord checks every token where it's set; see Valid tokens.

Defaults

RoleColourEmojiButton style
primary#7680F4—ButtonStyle.Primary
neutral#888B95—ButtonStyle.Secondary
success#26A042✅ButtonStyle.Success
warning#B08400⚠️—
danger#E3606D⛔ButtonStyle.Danger
info#1699AEℹ️—
loading—⏳—

The success, warning, danger and info colours keep the hues of 4.0's Theme, with their lightness moved until each gives at least 3:1 against every surface an embed's stripe or a container's accent sits on in Discord's light, dark, darker and midnight themes: the contrast WCAG 2.1 asks of a graphic that carries meaning. A test in MeoCord holds every default to it, so a default that changes still reads on light and dark alike.

Valid tokens

  • A colour is a 6-digit hex string such as '#7680F4', with or without #; a whole number from 0 to 0xFFFFFF; an [r, g, b] tuple of whole numbers from 0 to 255; or a discord.js colour name such as 'Blurple'. A 3-digit hex string such as '#FFF' isn't one.
  • An emoji is one unicode emoji, flags, keycaps, skin tones and joined sequences such as '👨‍👩‍👧' included, or a custom one written <:name:id> or <a:name:id>. A shortcode such as ':smile:' isn't one. A custom emoji must also be one the bot may use, such as an emoji the application owns.
  • A button style is ButtonStyle.Primary, Secondary, Success or Danger.
  • A role MeoCord reserves is refused in colors, emojis or buttons, in JavaScript as in TypeScript.

Each problem is named with its key path and what to give instead, such as theme.colors.primary: '#GGG' is not a colour: give a 6-digit hex string such as '#7680F4', …. MeoCord's groups are checked whatever roles an app added to them; a group of the app's own is the app's to check.

A theme set in code is checked where it's declared. A bad token in @MeoCord({ theme }) or @UseTheme stops the bot before it logs in. The message names where the theme was set, then each token it refuses, as in ShopController.refund: @UseTheme: the theme has 1 problem: followed by theme.emojis.loading: ….

Reading the theme

useTheme() returns the theme of the running call, with every role present. It works anywhere the call runs: in the handler, in a service or a presenter it calls, and in a timer or a promise it starts. A guard, an interceptor or a filter reads the same theme as context.getTheme().

Outside any call, as in a scheduled job or an onShutdown hook, useTheme() returns the app's theme from when its start begins until the start fails or the app has shut down, and MeoCord's defaults otherwise.

Collectors and listeners

A collector's collect callback, or a client.on(...) listener, is called by its emitter, outside the call that set it up. respond(click) there still takes the app's theme, with the server's and the user's over it, but not the handler's @UseTheme. To keep the handler's, wrap the callback in bindTheme:

controllers/slash/vote.slash.controller.ts
@Command('vote', CommandType.SLASH)
@UseTheme({ emojis: { success: '🗳️' } })
async vote(interaction: ChatInputCommandInteraction) {
  const yes = new ButtonBuilder().setCustomId('vote:yes').setLabel('Yes').setStyle(useTheme().buttons.success)
  const message = await respond(interaction).send({
    content: 'Ship it?',
    components: [new ActionRowBuilder<ButtonBuilder>().addComponents(yes)],
  })

  // The collector calls back from the client, outside this call: bindTheme keeps this handler's @UseTheme
  message?.createMessageComponentCollector({ time: 60_000 }).on(
    'collect',
    bindTheme(async (click: ButtonInteraction) => {
      await respond(click).send(`${useTheme().emojis.success} Counted`)
    }),
  )
}
Dispatches /vote

What respond() themes

An embed with no color, and a Components V2 container with no accent_color, sent through respond(), take the theme's primary. A colour you set is kept, 0 included. MeoCord's own views, the loading view and error answers, are styled by the presenter, which gets the theme and the error's tone.

To send one message as written, pass { fill: false } as the second argument to send(), edit() or followUp(), as /banner does above. The next message is filled again. What you send around respond(), with interaction.reply(), is never touched.

MeoCord's replies to a message, a usage error, a guard's or validation's reason, and a UserError's message, are plain text, which a theme leaves as it is unless the presenter draws them with messageError. With @MeoCord({ messages: { replyEmoji: true } }) each of them begins with the call's emojis.warning, as do the direct messages of dmOnError and dmOnCooldown, and the built-in help with its emojis.info: see Usage errors.

Per server and per user

themeFor looks a theme up by where a call comes from: a server's goes over the handler's, and a user's over the server's, in a server or in a DM.

app-with-theme-for.ts
@MeoCord({
  controllers: [StoreSlashController, ThemeSettingsSlashController],
  clientOptions: { intents: [GatewayIntentBits.Guilds] },
  theme: { colors: { primary: '#5865F2' } },
  themeFor: {
    // A server's theme goes over the handler's, and a user's over the server's; either may be async
    guild: ({ guild }) => guildThemes.get(guild.id),
    user: ({ user }) => userThemes.get(user.id),
  },
  themeCache: { ttlSeconds: 300, maxGuilds: 10_000, maxUsers: 50_000 },
  themeForTimeoutMs: 1_000,
})
export default class App {}

Each resolver returns part of a theme, or undefined for none, at once or as a promise. Results are cached for themeCache.ttlSeconds, five minutes by default, and calls that ask at the same time share one lookup. When a server's theme changes, clear its cached result so the next call looks it up again:

controllers/slash/theme-settings.slash.controller.ts
@Controller()
export class ThemeSettingsSlashController {
  constructor(private readonly themes: ThemeCache) {}

  @Command('settings accent', CommandType.SLASH)
  async accent(interaction: ChatInputCommandInteraction) {
    // Checked when the theme is looked up: a bad colour is left out, with a warning, rather than failing the call
    const colour = interaction.options.getString('colour', true) as HexColorString
    guildThemes.set(interaction.guildId!, { colors: { primary: colour } })
    // The next call from this server looks its theme up again, rather than waiting for the cached one to expire
    this.themes.invalidateGuild(interaction.guildId!)
    await respond(interaction).send(`Accent set to ${colour}`)
  }
}

To look a theme up with the app's services, such as a user's choice saved in a database, give themeFor a class implementing ThemeResolver, decorated with @Service(). Its guild() and user() methods are the resolvers, each optional. The class is resolved from the app's container, as the cooldown store is: it isn't listed in providers, its constructor injects the app's services and providers, and it runs OnReady and OnShutdown as a service does. Its results are cached as the functions' are, so the service that saves a choice injects ThemeCache and clears it:

app-with-theme-resolver.ts
@Service()
export class PrefsService {
  // A database in a real bot, which a provider connects
  private readonly choices = new Map<string, ThemeOverride>()

  constructor(private readonly themes: ThemeCache) {}

  async themeOf(userId: string): Promise<ThemeOverride | undefined> {
    return this.choices.get(userId)
  }

  async choose(userId: string, theme: ThemeOverride) {
    this.choices.set(userId, theme)
    // The user's next call looks their theme up again
    this.themes.invalidateUser(userId)
  }
}

@Service()
export class UserThemes implements ThemeResolver {
  constructor(private readonly prefs: PrefsService) {}

  user({ user }: UserThemeTarget) {
    return this.prefs.themeOf(user.id)
  }
}

@MeoCord({
  controllers: [StoreSlashController],
  clientOptions: { intents: [GatewayIntentBits.Guilds] },
  themeFor: UserThemes,
})
export default class App {}

A call's theme is looked up once, as the call starts, and holds for the whole call. The call that saves a new choice still answers in the old palette, and the user's next call gets the new one.

A resolver that throws, or passes themeForTimeoutMs, leaves its layer out of that call, and the call goes on. That server or user isn't asked again for 10 seconds, so its calls meanwhile go without it too. A result that isn't a valid theme is left out with a warning.

Adding tokens of your own

Declare roles of your own, or groups of your own, in src/types/theme.d.ts, which the create command writes for you:

augmented/types/theme.d.ts
import 'meocord/interface'
import { type ColorResolvable } from 'discord.js'

declare module 'meocord/interface' {
  interface ThemeColors {
    vip: ColorResolvable
  }
  interface MeoCordTheme {
    charts: { axis: ColorResolvable; series: ColorResolvable[] }
  }
}

Your tokens have no default, so your root theme has to set them, and TypeScript says so if it doesn't. useTheme() then always has them:

augmented/vip.app.ts
@MeoCord({
  controllers: [VipSlashController],
  clientOptions: { intents: [GatewayIntentBits.Guilds] },
  // MeoCord's roles are optional here; the app's own, which have no default, are not
  theme: {
    colors: { vip: '#D4AF37' },
    charts: { axis: '#888B95', series: ['#7680F4', '#26A042'] },
  },
})
export default class App {}
Dispatches /vip

A few names are reserved for roles MeoCord may add later, such as accent and brand. Taking one is a type error at your root theme, naming each.

Testing a themed bot

A testing module runs each call in its theme, as the bot does. Give the module a theme with overrideTheme, and compare against createMockTheme(), a whole theme with MeoCord's defaults:

controllers/slash/store.slash.controller.spec.ts
describe('StoreSlashController', () => {
  const module = MeoCordTestingModule.create({ controllers: [StoreSlashController] })
    // A theme for the module, as @MeoCord({ theme }) gives one; each @UseTheme still goes over it
    .overrideTheme({ emojis: { success: '🎉' } })
    .compile()
  // The embed of the call that sent one: under @Defer, the edit after the deferral
  const embedOf = (interaction: ChatInputCommandInteraction) => {
    type Sent = {
      embeds?: ({ toJSON(): { description?: string; color?: number } } | { description?: string; color?: number })[]
    }
    const sent = getResponse(interaction)
      .calls.map(call => call.payload as Sent)
      .find(payload => payload?.embeds?.length)
    const [embed] = sent!.embeds!
    return 'toJSON' in embed ? embed.toJSON() : embed
  }

  it("writes a receipt with the theme's success emoji and colour", async () => {
    const interaction = createMockInteraction(ChatInputCommandInteraction)

    await module.invoke(StoreSlashController, 'receipt', interaction)

    expect(embedOf(interaction)).toMatchObject({
      description: '🎉 Paid',
      color: resolveColor(createMockTheme().colors.success),
    })
  })

  it("fills an embed sent with no colour with the handler's primary", async () => {
    const interaction = createMockInteraction(ChatInputCommandInteraction)

    await module.invoke(StoreSlashController, 'refund', interaction)

    expect(embedOf(interaction).color).toBe(resolveColor('#E3606D'))
  })

  it("leaves the banner's stripe as Discord draws it", async () => {
    const interaction = createMockInteraction(ChatInputCommandInteraction)

    await module.invoke(StoreSlashController, 'banner', interaction)

    expect(embedOf(interaction).color).toBeUndefined()
  })
})

To run a service in a theme without a module, use withTheme(theme, fn). See Testing recipes for both, and for overrideThemeFor, which replaces the app's themeFor in a module.

From the Theme class

Theme from meocord/common still works, and it's deprecated. Each of its colours reads the matching role of the call's theme, and errorColor is danger. Read the theme with useTheme() in new code, and set colours in @MeoCord({ theme }). The migration guide lists the old values if you want to keep them.

Gotchas

  • The augmentation file needs its import. Without import 'meocord/interface' at the top, declare module replaces the module instead of extending it, and every other import from it stops compiling.
  • A theme kept in a variable isn't checked for typos. TypeScript checks only object literals, so write it with satisfies ThemeOverride.
  • A theme from a database must be a plain object. A class instance, such as an ORM row, is refused: return row.toObject() or { ...row }.
  • A ThemeResolver class declares its resolvers as methods. user = () => … is a property of each instance, not of the class, so it is not read, and a class with only such properties is refused. Without @Service(), a constructor that injects is refused too.
  • A user resolver runs for every message a message handler takes. Keep it cheap; its result is cached per user.

Build it

The feedback bot's review posts have no colour, and every verdict takes MeoCord's default primary. Give the bot a colour of its own:

tutorial/app.ts
// The bot's own colour; every other role keeps MeoCord's default
theme: { colors: { primary: '#5865F2' } },

The review post is sent with channel.send, not respond(), so it reads the theme itself:

tutorial/feedback.controller.ts
import { useTheme } from 'meocord/common'
// …
.setColor(useTheme().colors.primary)

Colour each verdict by what it means, and give the review buttons a loading emoji of their own:

tutorial/review.controller.ts
import { useTheme } from 'meocord/common'
import { UseTheme } from 'meocord/decorator'
// …
@UseTheme({ emojis: { loading: '📝' } })
// …
verdict.setColor(useTheme().colors[status === 'approved' ? 'success' : 'neutral'])

Submit a report, then approve one and reject another: the post arrives in the bot's colour, an approval turns it green and a rejection grey, and 📝 shows while each is saved.

Next steps