Settings and Storage
Typed settings and a generated settings panel.
Declare your settings once in settings.ts, beside src/. The build types them and draws the settings panel for you.
Quick Start
import { defineSettings, setting } from '@unbound-app/settings/schema';
export default defineSettings({
greetOnStart: setting.boolean({
default: true,
title: 'GREET_ON_START',
subtitle: 'GREET_ON_START_SUBTITLE',
}),
duration: setting.integer({ default: 3, min: 1, max: 10, title: 'DURATION' }),
theme: setting.choice({
default: 'dark',
title: 'THEME',
options: { dark: 'THEME_DARK', light: 'THEME_LIGHT' },
}),
appearance: setting.section(
{ title: 'APPEARANCE' },
{ accent: setting.string({ default: '#5865F2', title: 'ACCENT', displayAs: 'color' }) },
),
});Every title, subtitle, placeholder, confirm and option label is a message key, not text. A plugin with a settings.ts must have an i18n/en-US.json. A key missing from en-US is a type error.
{
"GREET_ON_START": "Greet on start",
"GREET_ON_START_SUBTITLE": "Show a greeting when the plugin starts.",
"DURATION": "Duration",
"THEME": "Theme",
"THEME_DARK": "Dark",
"THEME_LIGHT": "Light",
"APPEARANCE": "Appearance",
"ACCENT": "Accent"
}Read and write settings from @unbound-app/settings. Reads apply defaults. A write is checked against the schema and saved at once.
import { settings, useSetting } from '@unbound-app/settings';
settings.greetOnStart; // boolean, the default until the user changes it
settings.appearance.accent = '#FF0000'; // saved, and every useSetting of it re-renders
function Badge() {
const accent = useSetting('appearance.accent'); // re-renders only when the accent changes
const appearance = useSetting('appearance'); // the whole section, re-rendering when one of its settings changes
}ubd create writes both files with one example setting. A stored value the schema rejects, such as one an older version saved, is logged once and reset to its default.
Exports
Only settings.ts may import @unbound-app/settings/schema. Plugin code reads @unbound-app/settings. Full signatures: settings in the SDK reference.
@unbound-app/settings/schema | Declares |
|---|---|
defineSettings(properties, options?) | The default export of settings.ts. options.title names the top-level section |
setting.boolean, number, integer, string, choice, array | A stored setting. See Builders |
setting.section(options, properties) | A titled group, one level deep |
setting.button({ action, ... }) | A row that runs an action. Stores nothing |
setting.notice({ title, subtitle?, tone?, visibleWhen? }) | A message row. Stores nothing |
setting.string() and setting.choice({ options }) with no default are array items.
@unbound-app/settings | What it is |
|---|---|
settings | Every setting, typed. Assign a property, or a section's, to save it |
useSetting(key) | Reads a setting or section by dot-separated key. Re-renders when it changes |
resetSettings() | Resets every setting to its default |
SettingsPanel | The generated panel |
Settings, SettingKey, SettingsEntry<K>, ActionName | Types. See Types |
Builders
displayAs picks how a setting is drawn. Its first listed value is the default. Display options are accepted only with that display.
| Builder | Stored | displayAs | Options |
|---|---|---|---|
boolean | boolean | switch, checkbox | |
number, integer | number | input, slider (default with min and max), stepper, duration (ms) | min, max; slider/stepper: step (required for a number stepper); duration: units |
string | string | input, multiline, secret, url, color, user, channel, guild, role, emoji, image, font | input/multiline: minLength, maxLength, placeholder; color: alpha |
choice | a key of options | radio (default up to 5), picker (default above 5), segmented (up to 4) | options: a key, or { title, subtitle }. Only radio shows subtitles |
array | a list of of | of: setting.string(): list, user, channel, guild, role; of: setting.choice({ options }): checkboxes | minItems, maxItems |
section | an object | group, collapsible | |
button | nothing | button, danger | action, confirm |
notice | nothing | notice | tone: info (default), warning, danger |
The stored type follows the display:
| Display | Stored as |
|---|---|
color | `#${string}` |
url | An http or https URL, or empty |
user, channel, guild, role | A Discord id, or empty |
emoji | Its name, or name:id for a custom emoji |
Each display uses Discord's own components. stepper is Unbound's own, since Discord has none.
Common Options
| Option | What it does |
|---|---|
title | The row's label. Required unless hidden |
subtitle | The row's sub-label |
hidden: true | Stored and typed, not drawn |
visibleWhen | Draws the row only while it holds |
disabledWhen | Draws the row disabled while it holds |
requiresRestart: true | Changing it shows a "Restart Discord to apply" row with a restart button |
visibleWhen and disabledWhen take a boolean setting's key, or a predicate:
import { defineSettings, setting } from '@unbound-app/settings/schema';
export default defineSettings({
showBadge: setting.boolean({ default: true, title: 'SHOW_BADGE' }),
style: setting.choice({
default: 'roomy',
title: 'STYLE',
options: { compact: 'STYLE_COMPACT', roomy: 'STYLE_ROOMY' },
}),
locked: setting.boolean({ default: false, title: 'LOCKED' }),
size: setting.integer({
default: 14,
min: 10,
max: 24,
title: 'BADGE_SIZE',
visibleWhen: (settings) => settings.showBadge && settings.style !== 'compact',
disabledWhen: 'locked',
}),
});The build copies each predicate into your plugin, so it must be pure. It reads only its parameter, its own variables and built-ins such as Math. It assigns, constructs and imports nothing. The panel reruns it on every settings change. A predicate that throws is logged once, and its row stays shown and enabled.
Buttons
A setting.button runs a function exported by name from src/actions.ts, as a function or a const arrow. Each action takes nothing and returns void or a promise. action is typed from those exports, and the build checks it too. The action runs in your plugin, with its own unbound.
import { settings } from '@unbound-app/settings';
export function clearStarred() {
settings.starred = [];
}import { defineSettings, setting } from '@unbound-app/settings/schema';
export default defineSettings({
clear: setting.button({
title: 'CLEAR_STARRED',
action: 'clearStarred',
displayAs: 'danger',
confirm: 'CLEAR_CONFIRM',
}),
});confirm asks a question first. An action that throws is logged as Failed to run settings action <name> for <plugin id>: and shown in a toast.
Notices
A setting.notice shows a message and stores nothing, so it is not in settings.
import { defineSettings, setting } from '@unbound-app/settings/schema';
export default defineSettings({
size: setting.integer({ default: 14, min: 10, max: 24, title: 'BADGE_SIZE' }),
sizeWarning: setting.notice({
title: 'SIZE_WARNING',
subtitle: 'SIZE_WARNING_DETAIL',
tone: 'warning',
visibleWhen: (settings) => settings.size > 20,
}),
});A notice takes no hidden, disabledWhen or requiresRestart. It stands alone in its section.
Types
| Type | What it is |
|---|---|
Settings | The type of settings |
SettingKey | Every setting's and section's dot-separated key, hidden ones included. Buttons and notices have none |
SettingsEntry<K> | The value at K, such as the union of a choice's keys |
ActionName | The functions src/actions.ts exports |
import type { SettingsEntry } from '@unbound-app/settings';
function paint(color: SettingsEntry<'appearance.accent'>) {}Custom Panel
The generated panel has a row per setting, a section per setting.section, and a "Reset all to defaults" row. Its rows are public in ui: SwitchRow, CheckboxRow, SliderRow, StepperRow, NumberRow, DurationRow, InputRow, MultilineRow, SecretRow, UrlRow, ColorRow, EntityRow, RadioRow, PickerRow, SegmentedRow, ListRow, ChecklistRow, ButtonRow.
Export a SettingsPanel from your entry to replace it, such as for a live preview. It can still render the generated panel:
import { SettingsPanel as GeneratedPanel } from '@unbound-app/settings';
export function SettingsPanel() {
return <GeneratedPanel />;
}Buckets
Your plugin's settings live in its bucket, named after its manifest id. Store other data there too.
storage | What it does |
|---|---|
getBucket(name?) | Your plugin's bucket, or the one called name |
onChange(predicate, callback) | Calls callback for each change predicate accepts. Returns a remover. Removed when your plugin stops |
snapshot(options?) | Reads every bucket. secret settings are masked unless includeSecrets is set |
Full signatures: storage in the SDK reference.
| Bucket method | What it does |
|---|---|
get(key, default) | Reads a value, or the default when unset |
set(key, value) | Saves a value |
toggle(key, default) | Flips a boolean. An unset key starts from the default |
remove(key) | Deletes a value |
clear() | Deletes every value |
Build Errors
The build reports every problem in settings.ts in one run. ubd build fails. ubd dev keeps your last good build.
settings.ts:2:33 a title "Greet" is not a key of i18n/en-US.json. Fix: add "Greet" (or "A") to i18n/en-US.json.
settings.ts:5:91 b.visibleWhen reads outer variable "limit". Fix: read only the predicate's `settings` parameter; the predicate runs in the plugin bundle without settings.ts.
settings.ts:8:2 layout shows 5 options as segmented, which fits at most 4. Fix: use displayAs: 'radio' or 'picker'.A value import of @unbound-app/settings/schema, typebox or settings.ts from src/ fails with Fix: read settings from '@unbound-app/settings'. import type is allowed. TypeBox runs only at build time and never ships.
Typings
ubd build and ubd dev write src/unbound-settings.d.ts (the builders and your settings) and src/unbound-i18n.d.ts (your message keys). Hovering a setting shows its en-US title and subtitle. ubd dev rewrites them when settings.ts or i18n/en-US.json changes. Commit both, so your editor knows your settings before the first build.