api scene config - Drowbe/coffee-pub-blacksmith GitHub Wiki
Audience: Developers adding a tab to Foundry's Scene Configuration sheet from a Coffee Pub module.
Covers the registration surface for Scene Config tabs and the constraints a tab's render callback
must satisfy. Implementation lives in scripts/manager-scene-config.js.
Foundry's Scene Configuration sheet is an ApplicationV2. Injecting a tab into it correctly is harder than it looks, and the failure modes are silent: a duplicated panel on every render, or a tab that disappears between reloads. This API owns that handling so each module does not re-derive it.
Register a tab and Blacksmith injects it on every Scene Config render, removing any stale copy first.
const blacksmith = game.modules.get('coffee-pub-blacksmith')?.api;
blacksmith.registerSceneConfigTab('my-module-terrain', {
label: 'Terrain',
icon: 'fa-solid fa-mountain-sun',
moduleId: 'my-module',
render: (scene) => `
<div class="form-group">
<label>Elevation</label>
<input type="number" name="flags.my-module.terrain.elevation"
value="${scene.getFlag('my-module', 'terrain')?.elevation ?? 0}" />
</div>`
});| Method | Returns | Purpose |
|---|---|---|
registerSceneConfigTab(tabId, tabData) |
boolean |
Register a tab. |
unregisterSceneConfigTab(tabId) |
boolean |
Remove a tab. false if it was not registered. |
getRegisteredSceneConfigTabs() |
Map |
Copy of the registry. |
isSceneConfigTabRegistered(tabId) |
boolean |
Whether an id is taken. |
These are assigned during Blacksmith's module load rather than filled in later, so they are callable
from a consumer's init.
| Key | Required | Notes |
|---|---|---|
label |
yes | Tab text. Set as textContent, so it is never parsed as markup. |
render |
yes |
(scene, app) => string. Must be synchronous. |
icon |
no | Font Awesome classes, e.g. 'fa-solid fa-mountain-sun'. Classes only, not markup. |
moduleId |
no | Owning module id. Defaults to Blacksmith's own id. |
tabId becomes the data-tab attribute, so prefix it with your module id to avoid collisions.
The tab button is built to match templates/generic/tab-navigation.hbs, so it lays out like a core tab:
the icon above the label, with the label in its own <span>.
Give the tab an icon and keep the label to a single short word. Tabs share the strip's width, so a
longer label wraps inside its own line box — and with no icon above it, the first line sits on the icon
row and the tab reads as misaligned against every core tab beside it. Every core tab is one word with an
icon; matching that is what makes yours look native.
There is no save callback, and none is needed. Foundry's own Scene Config form submission writes
any input named flags.<moduleId>.<path> onto the scene document, so naming your inputs correctly is
the whole of persistence:
<input type="checkbox" name="flags.my-module.terrain.rocky" />Your tab's inputs are part of the sheet's form whether or not the tab is the visible one, so any save submits them — including a save made from a core tab by someone who never opened yours.
A .tab is display: none until it also carries .active, and ApplicationV2 applies that class during
its own render, to its own parts. A tab injected afterwards starts with neither, which is invisible while
someone arrives by clicking it and breaks the moment the sheet opens with your tab already selected.
The injector applies the app's own tabGroups state to both the nav button and the panel, so you do not
need to think about it. Note the state, not the DOM, is the source of truth here: after a render, the
markup can show a different tab as active while tabGroups still holds yours, so a module doing its own
injection should read app.tabGroups[group] rather than inspecting classes.
Naming an input flags.<moduleId>.<path> hands persistence to Foundry, which is why there is no save
callback — but it also means the submit is not yours. Every guard you write sits on the read side, where
it protects your own code and does nothing to keep the document clean. Whatever the form submits is what
lands on the scene, verbatim.
That is usually invisible, because your reads normalise and everything looks correct. It stops being invisible when something reads the flag without going through you: a sibling module, a scene exported to a compendium and re-imported, or a person inspecting flags in a console. A downstream consumer defensive enough to cope will silently mask it, which delays discovery rather than preventing harm.
If a tab writes anything that needs normalising, put that on the write path with a preUpdateScene hook
rather than trusting the form. scripts/manager-geography.js does this — see GeographyManager.initialize
— and it is the reason the geography flag is canonical no matter who wrote it. The alternative, for a
module that would rather own its submit outright, is a window of its own rather than a tab.
Several checkboxes sharing one name look like the natural way to express a multi-select, and in Scene
Config they are broken twice over.
First, the sheet mirrors them. Scene Config's _onChangeForm "keeps form values with the same name
in-sync across the form" by copying the changed checkbox's checked onto every other element of that
name (client/applications/sheets/scene-config.mjs:609-613). Tick one box and all of them tick; untick
it and all of them untick. A group like this cannot be operated one box at a time.
Use a single <multi-checkbox name="flags.<moduleId>.<path>"> with <option value="..." selected>
children instead. It is one named element, so there is nothing to mirror, and it submits the ticked values
as a string[]. scripts/ui-scene-geography.js is the working example.
Second, the shared-name submission shape is surprising too. It is the shape older flags may still
carry. form.elements.namedItem(name) returns a RadioNodeList,
so FormDataExtended maps over every element
(client/applications/ux/form-data-extended.mjs:178-181), and a checkbox carrying a value attribute
yields field.checked ? field.value : null (:191-196). Twelve boxes with none ticked therefore submit
an array of twelve nulls — the key is present, and the value is neither [] nor absent.
That matters because null does not survive a round trip as nothing. String(null) is "null", which
is truthy, so the common normalizer shape list.map(String).filter(Boolean) turns the array into the
literal string "null" repeated once per box — data that looks populated and matches nothing. Filter for
the values you expect rather than for truthiness:
const selected = (Array.isArray(raw) ? raw : []).filter(v => typeof v === 'string' && VOCABULARY.includes(v));render runs inside a render hook. Foundry v13 replaces every template part on each render pass
(HandlebarsApplicationMixin#_replaceHTML calls priorElement.replaceWith), so an await inside the
callback lets a later pass detach the nav and body nodes captured beforehand. The tab then lands on
orphaned DOM and is absent, with nothing thrown and nothing logged.
Cache anything the callback needs — settings, compendium lookups, derived lists — before the sheet
opens, and read from that cache inside render.
A callback that throws, or returns anything other than a string, is skipped with a console message. Other modules' tabs on the same sheet are unaffected.
A registration is rejected, returning false with a console message, when tabId is not a non-empty
string, tabData is not an object, render is not a function, or label is not a non-empty string.
An id already registered by a different module is refused rather than overwritten — a silent overwrite would make the first module's tab vanish while its own registration call still reported success. Re-registering your own tab is allowed, and replaces your previous entry.