Repository navigation
architecture toast
Audience: contributors to the Blacksmith codebase.
How the on-screen toast primitive is built and why it is shaped the way it is. The public surface
is documented in api/api-toast.md; this doc covers the mechanism and the decisions.
Source: scripts/api-toast.js (ToastManager static class + ToastAPI bound-object surface).
Styles: styles/toast.css, @imported from styles/default.css (the mandatory import — a CSS
file outside that chain is silently unstyled).
The toast system was designed against two real implementations of a message splash in the suite.
The load-bearing observation: the cross-client part of a "toast to a
player" always already happened. Bibliosoph's splash renders on the receiving client after the
message arrived through Bibliosoph's own transport; Blacksmith's leader toast renders after the
partyLeader world-setting sync reached every client. The rendering primitive needs no sockets —
so it doesn't get any. Consumers call show() receipt-side, wherever their event lands.
There is no public cross-client send on this API — the rendering primitive takes no sockets at all. Keeping delivery out of it is what let it ship without a socket dependency, and it is also why callbacks can be stored as plain function references: toasts are per-client state that never serializes.
Receipt-side rendering is also where suppression lives, because every delivery path — a consumer's
local call, the broadcast relay, the targeted relay — ends in show() on the receiving client, so
one gate covers them all and senders need no knowledge of it. Two gates run at the top of show():
-
Publish surface. Foundry serves two player-facing views — the active tabletop (
/game) and the chat-only/streamcapture page (typically recorded by OBS) — and/streamloads modules like the tabletop does, so without a gate every toast would render on top of the chat capture with nobody behind the view to click it closed. Thepublishconfig ('game'default,'stream','both'; whitelist inToastManager.PUBLISH) names the target;show()compares it againstgame.viewand returnsnullon a non-targeted view.publishis plain data, so it crosses the relays unchanged and the receiving client decides — the same receipt-side model as_recipientstargeting. -
Excluded users. On the tabletop view,
show()returnsnullwhen the current user is on thetoastExcludedUsersworld setting (comma-separated Foundry user names, case-insensitive;isToastExcludedUser()inapi-toast.js). The stream view is exempt: exclusion protects a passive account from interactive noise on/game, while a stream-targeted toast is a deliberate publish to the capture surface — often logged in through that same account. The Send Toast window additionally filters excluded users out of its recipient list and its "Entire Party" resolution (window-toast-send.js) so the GM UI does not offer recipients who cannot be reached — a courtesy filter, not the enforcement point.
The menubar re-renders a Handlebars template into replaced DOM and guards the cost with a structure
fingerprint (api-menubar.js, §9B of architecture-blacksmith.md). Toasts do the opposite:
each toast element is built once with createElement and removed once — no template, no
re-render cycle, and therefore no fingerprint to keep honest (a class of bug the menubar
notifications work had to specifically fix). The tradeoff is that a toast is immutable after
show(); there is no update(). The stackKey replace-in-place covers the "latest state wins"
use case that an update API would otherwise serve. If an update() is ever added, it must rebuild
the element, not patch it.
Consumer strings (title, subtitle) land via textContent, never innerHTML — consumer data is
not parsed as HTML.
A single fixed container (#blacksmith-toast-container, top-center) is lazily created on first
show() and never removed; it has pointer-events: none so the empty space between toasts never
blocks canvas interaction (each toast re-enables its own pointer events). Active toasts live in a
Map keyed by toast id — insertion order is the visual stack order, newest at the bottom.
Three bounds keep the stack sane, all resolved inside show():
-
stackKeyreplacement — an existing toast with the same key is removed instantly before the new one is added (instant, not faded: a fade-out would overlap the incoming replacement). -
The cap (
MAX_STACK) — applies to transient, unsized toasts only: the oldest is evicted instantly when full. Two kinds sit outside it and are never evicted. Persistent (duration: 0) toasts, because "until closed" means it — a GM announcement must survive timer noise. And sized toasts, which are billboards rather than stack entries (see Sized toasts below). The filter is literally!persistent && !size. The unbounded-persistent case is accepted: persistent toasts come from deliberate acts, and each still has its × button. - Neither path fires
onDismiss— see the dismissal contract below.
Appearance is driven by parameters, not a closed style set: there is no semantic style
whitelist. Colour comes in as a color parameter, and presets live in the consumer (the Send
Toast tool's templates) rather than in the primitive — the primitive takes values, the caller decides
what they mean. size is the exception: it stays whitelisted (SIZES) and class-mapped, because
sizes are discrete presets rather than a free percentage, so every size is a tested CSS class.
Class-only styling has exactly three deliberate, sanitized inline exceptions:
-
backgroundImage— a cover background applied as an inline style. The path isencodeURId so quotes cannot escape theurl("")wrapper, and thehas-bgclass adds an automatic dark scrim (::beforeoverlay; content stacks above it) so text stays legible over arbitrary art. -
color— validated against a strict hex pattern (COLOR_PATTERN) and applied as the--blacksmith-toast-accentcustom property;toast.cssderives border/icon/title from it. A custom property carrying a validated hex cannot smuggle CSS. -
backgroundColor— same strict-hex validation, converted torgba()atBACKGROUND_ALPHA(0.9) and applied as an inlinebackground-color, independent of the accent. (An earlier iteration derived the background from the accent viacolor-mix; the author retired derivation — explicit beats clever.) AbackgroundImagecovers it when both are set; the border keeps its accent either way.BACKGROUND_ALPHAand the default background alpha intoast.cssmust stay in sync — toasts are deliberately translucent so the play area reads through them.
The GM tool's template selector is where presets live now that the primitive takes parameters.
Built-in templates are code-side constants in window-toast-send.js (BUILTIN_TEMPLATES) and are
not deletable; user templates are full snapshots saved by name in the world-scoped
toastSendTemplates setting (Save As / Delete in the window — Delete is only shown for user
templates). The built-in set is deliberately small — three adhoc presets forming an
escalation ladder (Information: content-fit, auto-dismisses; Announcement: small billboard,
lingers; Important: fullscreen, waits for a click), each named with an "(adhoc)" suffix —
because the wide middle is what Custom and user-saved templates are for.
A user template is a full snapshot: applying one stamps the appearance fields (border color,
background color, optional background image, icon/avatar mode, size, duration, sound, animation),
the publish target, and the title and message onto the form — a saved template can carry
Stream with it, so a canned stream overlay is one selection away. The adhoc built-ins are the
deliberate exception on text: they carry none, so selecting one clears the title and message
for fresh wording — a fixed design you type into each time, which is what the suffix signals.
(Templates saved before the full-snapshot model may lack text or a target; they stamp empty
wording and a Game target.) Built-ins are read-only presets; a GM's own templates are
documents. Editing an appearance field or the target while a built-in is selected forks the
form to the — Custom — sentinel (the selector never claims a built-in the form has diverged
from); editing while a user template is selected keeps the edits attached to it, and Save then
updates that template in place without prompting. Only Custom — an unsaved configuration —
prompts for a name. Built-ins show no Save at all. Recipients are the one thing never saved in a
template: who is online is situational, and the Quick Toast path always sends party-wide. Typing
in the title or message deliberately does not flip the selector to Custom: writing the
message is the normal use of a template, not a divergence from it. The color controls follow the
pin-configuration pattern (hex text + swatch, two-way synced). One trap worth remembering: a
template's sound is a path, not an asset id — the sound dropdown is keyed by path
(getSoundChoices in settings.js) and playSound() takes a src, so an id here 404s.
User templates with a title are also fireable without the window: the party menubar's Quick
Toast item (showQuickToastMenu in api-menubar.js) lists them in a UIContextMenu
and quickSendToastTemplate() (window-toast-send.js) sends the pick exactly as stored —
party-wide delivery (online non-GM users minus the Excluded Users list) on the template's own
publish target, with a stream target going out as a broadcast. The adhoc built-ins and titleless
pre-snapshot templates never appear in the menu — show() requires a title — so the list is
always the GM's canned announcements, and an empty list explains how to create one. The menu's
last entry opens the full Send Toast window.
The same menubar item is visible to the elected party leader (isCurrentUserPartyLeader()), and
opening it as a non-GM narrows it: only templates carrying leaderAccess — the GM's "Enable
Leader Access" checkbox, saved in the template snapshot like every other field — and no entry
for the Send Toast window, which is the GM's tool. One registration serves both, because the
menu already knows who opened it; two would mean keeping two visible predicates and two menus
honest with each other. templateData.isLeader is part of the menubar structure fingerprint, so
a leader change re-renders and the button arrives or leaves on its own.
The leader's recipients are not the GM's. A GM's Quick Toast goes to the party — online non-GM users minus the excluded. A leader's goes to every online user but themselves, GMs included: a leader announcing to the table means the table, and the GM is at it. The sender is dropped for the same reason the GM's own send is not echoed back — they get the small confirmation toast instead.
Nothing on the leader path writes: toastSendTemplates is world-scoped and read-only to a
player, and socketlib's executeForOthers is not GM-gated, so the existing relay carries a
player's send with no new plumbing. Delivery is still gated receipt-side by _recipients, which
is the same guarantee it always was — and the payload is a GM-authored announcement either way,
so the "never carry secrets" rule is untouched.
leaderAccess also explains why the Send Toast window now lists GMs as tickable recipients.
Without them a leader could not reach the GM at all, and neither could a co-GM reach another. They
are listed badged GM, after the players, and "Entire Party" still means online players only —
a GM is included by ticking them, the deliberate act offline and excluded rows already require.
Widening the party checkbox instead would have changed what an existing control has always meant
and made every party-wide send echo on the sender's own screen. Because a GM row stays enabled
while the party box is checked, the send unions the ticked ids with the resolved party rather than
replacing them; the party box locking the player rows must not silently discard a tick beside it.
Optional sound is a data path, not a shared audio instance. show() plays it locally through
Blacksmith's sound helper. Internal broadcast/targeted relays carry the path, and each receiving
client plays the sound when it renders the toast.
Sized toasts are billboards, not stack entries. The display model
is binary: no size = a toast (content-fit, stacks top-center); any size (small/medium/
large/fullscreen) = a billboard — a viewport-proportional box in both dimensions,
centered, rendered inside a dedicated fixed full-viewport layer (#blacksmith-toast-billboard-layer)
so the stack container's flex layout never sees it. The layer's positioning is inline and
JS-owned — a deliberate guard, not a style: a billboard appended as a plain <body> child with a
stale or missing stylesheet becomes a static block in Foundry's body layout and physically shoves
the interface around (observed live 2026-07-19 via a cached toast.css). The layer makes that
failure mode impossible; broken CSS now degrades to "billboard renders unstyled," never "UI moves."
Width and minimum height are tuned per preset rather than one shared percent (a single percent of
both axes goes shapeless on ultrawide monitors), and typography is clamp'd viewport units so
content scales relationally with the box. The preset height is a minimum: the box grows with
its content, so ordinary messages never scroll (author decision 2026-07-24 — a scrollbar inside a
billboard is a design failure). A 90vh cap keeps pathological content from pushing the box past
the screen; only past that cap does the text block fall back to scrolling. Billboards are singletons (a new one replaces the current, whatever its size — two
simultaneous centered takeovers is meaningless), exempt from the stack cap like persistent toasts,
and — when there is no onClick — a click anywhere dismisses through _dismiss (the player let it
go by, so onDismiss fires). Billboards carry their own centering transform, so the stack's
slide-in transition is overridden with a scale-in. The remove/clearByModule/getActive
surfaces treat a billboard like any other toast because it still lives in the Map.
onDismiss means one thing: the user let this go by — auto-timeout or the × button. Every other
removal is silent: post-onClick removal, programmatic remove(), clearByModule(), stackKey
replacement, cap eviction. This is the same contract shipped for menubar notifications
(api-menubar.md dismiss table) — one contract across both transient surfaces, so a consumer
handling "went away unread" bookkeeping (Bibliosoph unread counts, Squire notification-id tracking)
writes the same logic for either.
Internally that contract is a two-method split, mirroring api-menubar.js: _dismiss() (fires
onDismiss, then removes) is called only from the timeout and the × handler; _remove() is the
silent workhorse everything else uses. The × handler calls stopPropagation() so a close click
never reaches the body's onClick listener.
Enter/exit is CSS-transition driven: the element is appended, then .visible is added on the next
requestAnimationFrame (so the transition actually runs); exit removes .visible and deletes the
element after ANIMATION_MS. ANIMATION_MS in api-toast.js and the transition duration in
styles/toast.css must stay in sync — the JS value is how long the element lingers for the CSS
fade to finish.
The call to action (the callToAction config; size whitelist in ToastManager.CTA_SIZES —
small/medium/large, deliberately not stacked toasts or fullscreen) is affordance, not a
control: the button-styled element sits inside the toast's single click target and the body
onClick handles the click, so the one-action contract is untouched — Phase 2's actions row
remains the design space for genuinely multi-button toasts. show() refuses a CTA without a live
onClick (a "click to do X" label with no handler would lie), which combined with the relays
stripping callbacks means a relayed toast never shows one — the audience is consumers calling
show() receipt-side, e.g. a rolls consumer putting "Roll for the Crit Card" on a crit
billboard.
Content animations (the animation config; whitelist in ToastManager.ANIMATIONS, keyframes in
styles/toast.css) are deliberately separate from that enter/exit machinery: they are pure CSS
keyframes scoped to the content children (icon/image, title, subtitle), never the container, so
they add no second JS/CSS timing sync and cannot fight the .visible transition. They are
billboard-only — show() refuses an animation without a size (author decision 2026-07-23):
stacked toasts fire from timers and announcements, and several toasts animating at once is noise,
so the expressive lane is the sized takeover. All are entrance-run-once except pulse, a subtle
infinite breathe meant for persistent billboards — the one sanctioned loop, bounded by billboards
being singletons. Transform/opacity only (compositor-friendly). Not gated on prefers-reduced-motion — a
billboard's entrance is the announcement, and on Windows that query follows a single OEM-settable
"Animation effects" toggle, so gating it silenced the effect for people who had chosen nothing. See
the note in architecture-menubar.md. Because billboards replace rather than stack,
a replacement re-runs the entrance — correct there, since a new billboard is new content.
broadcastToast(config) in api-toast.js shows a toast on every connected client: locally via
show(), remotely via the showToast socketlib handler in manager-sockets.js. It exists for
announcements that originate on one client (the GM's timer helpers) and must reach players, where a
chat card is the alternative transport. It is deliberately not on ToastAPI: there is no public cross-client
toast surface, and this relay is Blacksmith-private plumbing — data-only by construction (callbacks
are stripped before the socket). SocketManager is imported dynamically inside broadcastToast to
avoid a static import cycle (manager-sockets statically imports api-toast for the handler).
timer-notifications.js is the first consumer: routeTimerNotification(settingKey, label, stackKey, data) is the shared channel router for the three timer announcement helpers — it maps
the timer payload flags to toast content, broadcasts the toast half, and returns whether the caller
should still post its chat card. The per-kind toggles in each timer's own settings section gate the
calls before they reach the router: the timer section decides what fires, the Notifications
section decides where it goes.
sendToastToUsers(config, userIds) is the relay's targeted sibling, same internal-only
standing. Targeting is receipt-side per the socket privacy rule (both transports broadcast):
_recipients rides the payload and the showToast handler renders only on listed clients — so
the payload must never carry secrets; a GM announcement is non-secret by contract. The sender's
own client renders locally only if it is in the list. One exemption: the stream surface is
view-addressed, not user-addressed — when a payload's publish targets the stream, the
handler renders it on any /stream client regardless of _recipients, because whoever is logged
into the capture page is incidental to the send; show()'s own view gate keeps the exemption from
leaking anything onto /game. First consumer: the GM Send Toast tool (window-toast-send.js,
opened from the party menubar, GM-only) — it sends size: 'large' toasts to selected players and
shows the sending GM a small confirmation toast rather than an echo of the announcement. Second
consumer: the Hurry Up nudge (sendHurryUpNudge in timer-notifications.js, gated by the
notifyHurryUp channel setting) — a small shake billboard wearing the slow combatant's
portrait, sent either direct (targeted to the active owners of the slow combatant's actor, with a
local confirmation toast for the sender; when no owner is online it is not delivered and the
sender gets a local notice — the channel setting is absolute, never a chat fallback) or as a
blast through broadcastToast to every client (the combat bar menu offers both scopes; the
combat tracker's timer overlay always blasts). Its
Target section maps directly onto publish: Game sends to the selected recipients as before,
Both adds the stream surface to the same send, and Stream drops user recipients entirely (the
section dims) and goes out through broadcastToast — every client hears it, only /stream pages
render it. That tool is a Blacksmith feature consuming private plumbing — there is no public
cross-client toast surface for other modules to call.
Each feature migrated from chat cards to toasts gets a world-scoped channel setting in the
Notifications settings section (settings.js, NOTIFICATION_CHANNEL_CHOICES): toast /
chat / both / none, defaulting to toast. The gate lives at both ends — the toast half
is checked receipt-side inside the feature's updateSetting hook (every client reads the same
world value), the chat half GM-side at the ChatMessage.create site. Live examples:
notifyLeaderChange (api-menubar.js) and notifyMovementChange (token-movement.js).
_registerLeaderChangeHook in api-menubar.js listens to the core updateSetting /
createSetting document hooks for the partyLeader world setting — those fire on every
client, so the toast is receipt-side: the new leader's client gets "You are now the party leader",
everyone else gets the actor's name, stackKey: "blacksmith-party-leader" so rapid re-picks
replace rather than stack. (This site originally listened to settingChange, a hook that does
not exist in Foundry — see architecture-blacksmith.md §9B.2 for the suite-wide
fallout; the leader display had always synced via the socketlib updateLeader broadcast, which
masked it.) Movement follows the identical shape in token-movement.js: movementType is a world
setting, its hook toasts receipt-side from the shared MOVEMENT_TYPES catalog,
stackKey: "blacksmith-movement". Both features' chat cards still exist but are gated by their
notifyX channel setting (default toast — so chat is off unless the GM opts back in).
The primitive renders; policy stays in the consumer. Which events toast, per-user enable settings, mention rules, auto-open fallbacks, whose avatar to show — all consumer-side (Bibliosoph keeps its splash settings; Squire keeps its dedup policy). Blacksmith owns only the rendering, stacking, and the dismissal contract.
Exclusion (toastExcludedUsers) began as all-or-nothing, which is right for a camera account that
must not have party chatter on screen and wrong for the case that motivates changing it: the
broadcast cameraman should see "FUMBLE!" and "CRITICAL!", because those are the moments the
broadcast exists to capture.
The obvious design is a per-user opt-in — each excluded user lists the event kinds they still
receive. It cannot be built without breaking the boundary. Those kinds belong to sibling
modules, so Blacksmith's settings UI would have to enumerate critical, fumble, injury and so
carry a vocabulary about dice outcomes it otherwise has no reason to know. The moment that list
exists, adding a new announceable event in a sibling means editing Blacksmith.
channel inverts it. The sender declares a free-form name and, through registerChannel, the
label the GM reads; Blacksmith stores both and compares strings. Nothing here knows what a critical
is, a sibling can introduce a new class of announcement without touching this module, and several
siblings can publish to one channel. isToastBypassChannel (api-toast.js) is the whole of the
test, and the exclusion check in show() is its only caller.
Read it as the shape to reach for whenever a consumer needs Blacksmith to treat some of its events
differently: take a name from the sender, never a taxonomy into the hub. Note that storing a
name a module gives you is not learning a vocabulary — the first version of this refused a registry
on that confusion, and it is the same trust registerMenubarTool already extends to module-supplied
titles and icons.
The check stays receipt-side alongside the rest of exclusion, so it changes what a client renders and never what was delivered — see "The internal broadcast relay" above, and note that a bypass channel therefore carries no implication of privacy.
registerChannel creates an ordinary Boolean world setting per channel (_ensureChannelSetting),
named and hinted from what the sender supplied. A channel is a label and a checkbox, which is
precisely what a Boolean setting is — so Foundry renders it, and there is no markup here at all.
The version before this kept one comma-separated field and drew a checklist into the settings form
from a renderSettingsConfig hook. It reimplemented in CSS what the settings form already does,
and got it wrong on the first try: the list was inserted into .form-fields, which is a flex row
for the input, so a five-row checklist and a text box shared one cell at half width each. Do not
reach for a settings-form hook to render something the form can render natively.
Two consequences worth holding onto:
-
Defaults are
true. A channel is a sender saying "this is a notable event", and the earlier allow-list defaulting to empty cost a table an evening's broadcast — a correct feature nobody switches on is the same as no feature. Unticking is how a GM narrows it. -
An unregistered channel has no setting and is therefore allowed. That is deliberate: a sender
need not register to work, and the absence of a checkbox never means the absence of delivery.
_seenChannelsnames such channels once per client in debug output, since they appear nowhere in settings; a registered one needs no such help.
Excluded-user names, unlike channel names, are validated. getUnknownExcludedUserNames() reports
names in toastExcludedUsers matching no user, and the setting warns the GM on change. Users are
enumerable, so a name matching nobody is a fact; a channel name is whatever a module chose to send.
Validate what you can enumerate.
Read-only introspection. isExcludedUser and isBypassChannel are on ToastAPI so a consumer
can warn its own GM that its announcements cannot arrive. They were internal at first, forcing a
sibling to read the settings and duplicate the parsing — and that coupling proved real immediately:
the storage behind channels changed from one comma-separated field to a setting per channel, and
callers of the helpers needed no change while a caller of the raw settings would have broken.
- Ai assistance
- Applicationv2 traps
- CSS traps
- dnd5e conditions
- Documentation standard
- Token actor identity
- Campaign
- Canvas
- Chatcards
- Compendiums
- Contextmenu
- Core
- Create journal entry
- Dialog
- Active Effects
- Entity list
- Equiplocations
- Geography
- GM request
- Gmnotes
- Health
- Hookmanager
- Importer
- Inventory
- Menubar
- Notes
- Party
- Pins
- Quantity split
- Requestroll
- Rolls
- Scene config
- Sockets
- Stats
- Tags
- Toast
- Tokens
- Toolbar
- Window
- Worldclock