Repository navigation
architecture tool windows
Audience: us, and the other Coffee Pub modules.
How the Dice Tray, Macros, and Health windows are built, and the two pieces of shared machinery they introduced: settings adoption from a sibling module, and menubar tool supersession.
The public surface is in documentation/api/api-window.md and documentation/api/api-menubar.md.
Each is a single file extending BlacksmithToolWindowBaseV2, with a template and a stylesheet of the same
name. There is no panel class. In Squire, where these came from, each was a panel plus a window shell; two
of the three panels existed largely to reach PanelManager for the current actor, and that need does not
exist here.
| Tool | Script | Menubar id | Window id |
|---|---|---|---|
| Dice Tray | scripts/window-dicetray.js |
dice-tray |
blacksmith-dice-tray |
| Macros | scripts/window-macros.js |
macros |
blacksmith-macros |
| Health | scripts/window-health.js |
health |
blacksmith-health |
The dice-tray menubar tool is no longer only a dice tray opener. It is the single menubar entry for
everything to do with rolling: a player's left click opens the Dice Tray, a GM's opens Request a Roll, and
its context menu carries both plus the manual-rolls toggle and the quick roll library. Its icon also reports
whether Foundry's manual rolls are on. The WINDOW in the table above is unchanged; the tool that opens it is
not. See architecture-rolls.md, "One menubar entry for rolling".
A fourth Tool window exists that is not in this table: the Roll Builder
(scripts/window-rollbuilder.js). It is deliberately absent, because it is ephemeral rather than
persistent — opened from an in-flow action, unregistered, a distinct id per instance and
rememberPosition: false, per the "Ephemeral tools" rules in ../api/api-window.md. Nothing here assumes a
Tool window is registered, and it is the worked example of that.
Registration happens explicitly from ready in scripts/blacksmith.js, not from each file's own ready
hook, so ordering against settings registration is decided in one place. Each register* function imports
registerWindow from api-windows.js and MenuBar directly rather than going through module.api --
the window methods attach to module.api from an async dynamic import, so they are not guaranteed to be
present at any particular moment.
Worth stating because conflating them caused a wrong assumption during the move -- the id that looked at-risk was not the one that was:
- The registry id is what
openWindowtakes. Renaming it breaks callers. - The ApplicationV2 id (
DEFAULT_OPTIONS.id) is the DOM id. Nothing persists against it. - The
windowPositionKeyis what position persists against, plus the titlebar-mode and theme preference keys derived from it. Renaming it silently resets all three.
BlacksmithWindowBaseV2.migratePositionKey(oldKey, newKey) moves the three localStorage keys once and
deletes the originals. It refuses to overwrite a value already present under the new key, so a second call
after the user has moved the window cannot undo their change.
Dice Tray and Macros title themselves with an actor, resolved by
canvas.tokens.controlled[0]?.actor ?? game.user.character. This is cosmetic in both cases: dice rolls use
ChatMessage.getSpeaker() with no argument, so Foundry resolves the speaker from the user's own token, and
a macro runs whatever its own script resolves.
Health is the one that reads its selection as data rather than decoration -- see below.
The window registers a controlToken hook through HookManager with a per-instance context, so closing it
disposes the hook rather than leaking a listener. The callback is debounced by one tick, because
Token#control fires the hook once per token and a multi-select would otherwise re-render once per token
in the selection.
Callers that need a set of tokens shown without changing what the GM has selected pass
openWindow('blacksmith-health', { tokens }). That is an option on the opener rather than a method on the
instance, so the surface stays a registry call and no caller needs to hold the window object.
_registeredActors is the unregister list, and it is load-bearing rather than tidy. With nothing selected
the window registers itself into actor.apps for every token on the scene that has hit points, so that
damage anywhere refreshes the Party and NPC summary rows. Without the set those entries cannot be found
again, and closing the window would leave apps references across the whole scene pointing at a dead
window.
Each individual health row can carry a button that opens a conditions editor. Blacksmith has no such
window, so the button renders only when some module has registered one under the window id
blacksmith-status-effects.
Naming a capability rather than a module is the same rule the party-health menubar intent follows: when
nobody provides it the button does not render, which is the correct behaviour for an optional integration
and avoids offering a click that would do nothing.
With the roll history hidden the window has no content tall enough to size against, so it opens at a fixed
150px and _fitHiddenHistoryHeight() measures the real content once rendered -- taking the last child's
bottom edge rather than the content box, because the content box keeps the height it had while the history
was visible. The pair is load-bearing: port both or neither.
_addDroppedMacro is guarded against re-entry. ApplicationV2 windows share Foundry's global drag surface,
so two handlers can observe one drop, and the body is a read-modify-write on the userMacros setting --
two concurrent adds lose one. External drops are captured at the content boundary so canvas and sidebar
handlers cannot also process them; internal reordering is allowed to reach the individual slot handlers.
scripts/utility-health.js holds the single definition of what "bloodied" means. It is read by the combat
bar portrait rings, the token blood indicators, and the Health window, and is exported so a consuming
module can use it instead of growing a fourth copy.
Boundaries come from the healthThresholdInjured / Bloodied / Critical world settings rather than
constants, and are inclusive: a creature at exactly the bloodied threshold is bloodied, which matches how
the settings describe themselves. getHealthThresholds reads settings directly with a hardcoded fallback,
so it works before settings registration and needs no window or manager instance -- a consuming module's
tray handle rebuilds on every render and cannot depend on a window being open.
hurt has no threshold of its own. It means "has taken damage but is above the injured line", which
distinguishes a scratch from an untouched creature; the blood indicators use it and the health bars do not.
scripts/manager-settings-adoption.js moves a setting from a sibling module's namespace into Blacksmith's,
once. It runs from ready after registerSettings(), and is deliberately non-fatal: a failed adoption
costs the user their old preferences, not the module, so it must not be able to stall loading.
Blacksmith owns this rather than the departing module because a departing module can only migrate if the user installs the one release carrying the migration before removing it, and nothing guarantees that ordering. Reading the old key from here is order-independent.
Reads take the registered path when the sibling is still installed, because that applies its declared type
and default, and fall back to the raw store otherwise -- game.settings.get throws on an unregistered key,
and uninstalling a module does not delete its stored settings. World and user scope share one store
(client-settings.mjs:42-46), distinguished by user id; client scope is localStorage holding the
serialised form.
There is one ledger per scope, because "has this been adopted" has a different answer for different people:
| Scope | Ledger | Why |
|---|---|---|
world |
adoptedSettingsWorld setting |
Adopted once for the world. GM only -- players cannot write world settings, and skipping them is correct rather than an error. |
user |
adoptedSettingsUser setting |
Each user owns their own value and can only write their own. |
client |
blacksmith-adopted-settings in localStorage |
Per browser, because that is where the values live. |
A row is marked done even when the source had nothing to adopt, so an absent key is not re-checked on every load forever, and it is left marked when a write fails -- a value that fails once fails every time.
A client-scope setting is per browser and no migration changes that. userFavoriteMacros is the case
that matters: a user logging in from a second machine starts with an empty favourites list.
Window-open state is adopted separately in scripts/manager-tool-windows.js, because it is a user flag
rather than a setting and the shape does not fit the table.
registerMenubarTool accepts supersedes: [toolId, ...]. A listed id already registered is dropped; a
listed id registering later is refused. Both halves are needed because module load order is not something
either module controls, so a rule that works only one way round is not a rule.
This exists for the window during which a tool has moved between modules and a user has updated one module
but not the other -- without it they see two identical icons. unregisterMenubarTool releases the claims a
tool made, or unregistering the new owner would leave the old one permanently unable to register.
Nothing declares supersedes today. The Squire adoption that prompted it shipped both modules together,
so no overlap ever reached a world. The mechanism is kept because the next extraction hits the same problem,
and because a supersedes entry is meant to be transient: it is a migration affordance with a defined end,
not a priority system, and it is removed once the old owner's release has shipped.
- 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