Theming
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
Before this
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:
@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 {}@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 })
}
}/receipt; /refund; /bannerOpen in playground/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:
- MeoCord's defaults;
@MeoCord({ theme }), the app's theme;@UseThemeon each class, from a base class down to the controller, as far up as@Controller({ inheritStages })lets the controller inherit;@UseThemeon the handler;- 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:
| Group | Roles | A value is |
|---|---|---|
colors | primary, neutral, success, warning, danger, info | a colour discord.js accepts: '#7680F4', 0x7680f4, [118, 128, 244] or a colour name |
emojis | loading, success, warning, danger, info | a unicode emoji, or a custom one written <:name:id>, or <a:name:id> when animated |
buttons | primary, neutral, success, danger | ButtonStyle.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
| Role | Colour | Emoji | Button 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 from0to0xFFFFFF; 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,SuccessorDanger. - A role MeoCord reserves is refused in
colors,emojisorbuttons, 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:
@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`)
}),
)
}/voteOpen in playgroundWhat 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.
@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:
@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:
@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:
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:
@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 {}/vipOpen in playgroundA 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:
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 modulereplaces 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
ThemeResolverclass 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
userresolver 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:
// 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:
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:
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
- Presenters: style MeoCord's loading and error views from the theme and the error's tone.
- Answering with respond(): what
respond()sends, and where the theme fills it. - Testing recipes: themes and
themeForunder test.