A native Swift app (launcher/macos, ~2 MB, no dependencies) that starts,
stops and watches over the server from the menu bar — and holds the macOS
permissions its modules need.
The permissions are the point as much as the buttons are. macOS grants
privacy permissions to the application that owns a process, and a child
inherits its parent's identity, so with the server started from here node —
and the Timers module's ltc-capture — belong to this app bundle and the
microphone and local-network prompts are asked, and remembered, as ProdDash.
Started over SSH or from a launchd job there is nobody to ask: the permission
is silently absent rather than refused, which is the "endless zeroes" failure
ltc-listener.js warns about.
The shell was already built for a supervisor: with PRODDASH_LAUNCHER=1 it
exits 75 instead of respawning itself when the admin page applies an update,
which the launcher reads as a restart request rather than a fault. Crashes
get a backed-off restart (1s, 2s, 5s, 10s) and four in two minutes stops the
cycle. The server is spawned into its own process group, so stopping it also
stops anything it started — an orphaned ltc-capture would otherwise hold an
audio device indefinitely — and signals, a logout and Quit all stop it first.
- Menu bar: ProdDash's own 2x2 tile mark, its accent tile lit green while
serving, amber mid-move, red when something needs a person. Start, stop,
restart, open the dashboard or admin, open at login, quit.
- Window: status and both addresses, plus Log (also written to
~/Library/Logs/ProdDash, kept a week), Settings, and Permissions.
- The port is written to the data directory's proddash.json — ProdDash's own
file — so the port set here holds however it is next started. Something
already on the port is reported, and offered as a takeover when it answers
like a ProdDash started another way.
- Modules declare what they need in their manifests ("permissions": [...],
documented in docs/MODULE-GUIDE.md and ignored by the shell); the launcher
lists exactly that, with each module's reason, asks for the microphone,
and — since macOS reports nothing about local network access — offers a
check that browses for services and tries every configured upstream.
Timers, ProdCom and ProPresenter modules declare their permissions (patch
bumps). Build with launcher/macos/build.sh; output is gitignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
28 KiB
ProdDash Module Guide
How to build, install and test a ProdDash module. Everything a module author needs is on this page — you never have to read the shell's source.
A module is one kind of dashboard tile: the live transcript, the Now/Next display, a clock. Users add any number of tiles of your module to their own dashboards, move and resize them, and give each tile its own per-tile settings. Your module renders inside the tile and talks to the outside world only through the small API the shell hands it.
Rules every module follows
These are not style preferences — the admin page, the installer and the people running the booth depend on them.
- All settings live in the ProdDash admin page. Server-wide settings go
in
configSchema, per-tile settings ininstanceSchema. Never a separate setup page, wizard, config file the operator edits by hand, or environment variable. - Dependencies ship inside the module folder. Zero dependencies is the
norm (the shell has none). Anything you genuinely need is vendored into
the module — or fetched by your own
init()on first start, with the admin health line saying what is happening. Nonpm install, no installer, no "run this first". - Everything the module does runs inside the module. No helper scripts,
companion programs, daemons or services the operator has to start next to
ProdDash. If your feature needs a listener, a decoder or a worker, your
server entry owns it: start it in
init(), stop it instop(), report it inhealth(). A child process the module itself spawns and supervises is fine; a program someone launches by hand is not. - Declare the ProdDash version you need (
"proddash": ">=1.1.0"in the manifest) and bump your ownversionwhenever you change the module. The admin page refuses to install a module whose requirement the running shell doesn't meet, and it offers updates by comparing versions.
The shape of a module
A module is one folder dropped into modules/:
modules/<id>/
├── module.json manifest (required)
├── client.js ES module, client entry (required)
├── server.js server entry (optional — only if you need server code)
└── ... assets (css, images…) served statically at /modules/<id>/
Installation is exactly this: in the admin page, open Available
modules and press Install — the module is downloaded from the ProdDash
repo into the server's data directory (<data dir>/modules/<id>/) and starts
immediately. Developing locally? Drop the folder into modules/ (bundled
with the checkout) or into the data directory's modules/ and restart. No
shell code changes, ever. Modules bundled with a checkout and modules
installed from the admin page live side by side; when both have the same id
the newer version runs. Uninstall in the admin page removes an installed
module's files (a bundled one is hidden instead, since its folder belongs to
the app); its settings are kept for a later reinstall.
module.json
{
"id": "my-module",
"name": "My Module",
"version": "1.0.0",
"proddash": ">=1.1.0",
"description": "One line shown in the Add-tile picker and the admin page",
"permissions": [
{ "kind": "microphone", "reason": "Why this module needs it, in one line" }
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 2, "h": 2 },
"defaultSize": { "w": 4, "h": 3 },
"configSchema": {
"upstreamUrl": { "type": "string", "label": "Upstream URL", "default": "http://10.0.0.5:1234" },
"apiKey": { "type": "password", "label": "API key", "default": "" }
},
"instanceSchema": {
"channel": { "type": "string", "label": "Channel filter", "default": "" }
}
}
| Field | Required | Meaning |
|---|---|---|
id |
yes | Must equal the folder name. Lowercase, dashes. |
name |
yes | Human name shown on the tile header and in menus. |
version |
yes | Your module's version (x.y.z). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. |
proddash |
yes | The ProdDash versions this module works with: ">=1.1.0", "^1.1.0", ">=1.1.0 <2.0.0", or "*". A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. |
description |
no | Shown in the admin page / picker. |
permissions |
no | What macOS must allow before this module can work — see Permissions macOS has to grant. Ignored by the shell; read by the macOS launcher. |
client |
yes | Client entry file, loaded as an ES module. |
server |
no | Server entry file (CommonJS), loaded with require(). |
style |
no | A stylesheet the shell injects once per module. |
minSize, defaultSize |
no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
configSchema |
no | Admin config — server-wide settings (URLs, ports, keys) edited once at /admin for everyone. |
instanceSchema |
no | Per-tile settings — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). |
tiles |
no | Fixed multi-tile list for client-only modules; server modules export a dynamic tiles() instead. See Presenting multiple tiles. |
Both schemas map field names to specs:
{ "type": "string" | "number" | "boolean" | "switch" | "select" | "color" | "password" | "endpoint",
"label": "Shown next to the field",
"default": <value>,
"group": "Section heading", // optional; see below
"showWhen": "<other boolean/switch key>", // optional; see below
"help": "One line shown under the control", // optional, admin config only
"options": [ { "value": "a", "label": "A", "group": "Optgroup" }, … ], // "select" only
"optionsRoute": "/devices", // "select" only, admin config only
"optionsDependsOn": "<other key>" // with optionsRoute; see below
}
boolean renders as a checkbox; switch is the same true/false value shown
as a toggle (use it for a feature on/off that other fields depend on).
color renders a swatch picker and stores a #rrggbb string (give it a hex
default; the tile applies it as a CSS custom property — see pco-plan).
group gathers consecutive fields under a labeled subsection in the admin
page, so a module with several distinct concerns (e.g. a ProPresenter
connection and an LTC audio input) reads as clearly separated blocks. Fields
with no group render loose, before/after the grouped ones in schema order.
A manifest-level configGroups map can dress a group up further, keyed by
the group name:
"configGroups": {
"Planning Center credentials": { "collapsed": "whenSet", "help": "Where to get a token…" },
"Show / hide": { "collapsed": true, "columns": 3 }
}
collapsed:true(starts folded),false(starts open but can fold), or"whenSet"— folded once the group already holds values (a saved password, or anything other than the schema default), open while it is still empty. Omit it and the group is a plain, always-open block.columns: 2–4 lays the group's fields out in a grid — the way to keep a long checklist of booleans compact.help: a line of guidance at the top of the group.
help on a field puts one line of guidance under its control — allowed
placeholders, where a value comes from. Keep labels short and put the
explanation there.
group and help work on instanceSchema fields too, and a manifest
instanceGroups map plays the configGroups role for the tile's gear
popover (collapsed: true | false, columns, help). That is how a module
with a long show/hide checklist keeps the popover usable — see pco-plan.
Display choices belong there, per tile; admin config is for connections.
showWhen: "<key>" hides a field until the boolean/switch field named
<key> is on — e.g. the audio device and channel appear only once the LTC
listener switch is enabled. The hidden field still keeps its stored value.
A select whose choices are only knowable at runtime (audio devices, serial
ports, discovered sources) sets optionsRoute instead of — or as a fallback
refresh of — static options: the admin page GETs
/api/modules/<id><optionsRoute> and expects { "options": [ { "value", "label" }, … ] } from one of the module's own server routes. The saved value
stays selectable even when the route is down or the option has vanished. (See
propresenter-timers' "Audio device" for a worked example.)
An option may carry a group: options sharing one render under a single
<optgroup>, so a tree (Planning Center folders, device classes) is
browsable inside the select. optionsDependsOn: "<key>" makes the admin
page fetch the options with that other field's current value appended as a
query parameter (?<key>=<value>), now and again whenever it changes — e.g.
a plan list that follows the chosen service type. (Both in pco-plan.)
password fields are admin-config only in practice: their values are stored
in the server's modules.json (in the per-machine data directory, see the
README), handed to your server entry, and never sent to browsers — not in /api/modules, and never echoed back into the admin
form. Anything upstream that needs the secret must go through your server
routes.
Connection settings: use endpoint
Anything that points at a device or service on the network — ProPresenter,
ProdCom, a camera, a mixer — must be one endpoint field, so every
module's connection is entered the same way. The admin page renders an
endpoint as a single "Host / IP : Port" control; the value is stored and
handed to your server as an object:
"propresenter": {
"type": "endpoint",
"label": "ProPresenter",
"default": { "host": "", "port": 1025 }
}
// in your server entry
const { host, port } = config.propresenter;
const base = `http://${host}:${port}`;
Do not split host and port into separate string/number fields, and
do not ask for a full http://… URL unless the setting genuinely is a
web address where protocol or path matter (a page to embed, a webhook).
Endpoints belong in admin config, not per-tile settings — connections are
server-wide by design.
Admin config lives in modules.json inside the server's data directory
(written by the admin page — your module never touches that file, and it
survives updates of the app folder). Per-tile settings live inside each
browser's layout.
Permissions macOS has to grant
Some things a module does need the operating system's permission, not just
configuration: reading an audio input, reaching gear on the local network.
macOS grants those to the application that owns the process, which for
ProdDash is the macOS launcher (launcher/macos) — the server is its child,
so the prompts are asked, and remembered, in ProdDash's name. A server
started over SSH or from a bare launchd job has no such owner, and the
permission is silently absent rather than refused: an LTC input reads as
endless zeroes, a local host simply never answers.
Declare what you need, and why, so the launcher's Permissions tab can list it and ask for it before a service rather than during one:
"permissions": [
{ "kind": "microphone", "reason": "Decoding SMPTE LTC timecode from an audio input on this machine" },
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
]
kind |
When to declare it |
|---|---|
microphone |
The module reads an audio input (ltc-capture and anything like it). |
local-network |
The module talks to gear on this building's network. Not needed for services out on the internet — Planning Center, say. |
reason is one line, shown to the operator as-is; modules giving the same
reason are listed together. A bare "permissions": ["microphone"] works when
the standing explanation is enough. A kind the launcher doesn't manage is
still listed, so nothing goes unmentioned — the shell itself ignores the key
entirely, and other platforms ignore it too.
Client entry (client.js)
An ES module whose default export is a factory. The shell calls it once per tile:
export default function create({ root, moduleApi }) {
return {
start() {}, // begin rendering / connecting
stop() {}, // tear down EVERYTHING (timers, streams, observers)
onResize(w, h) {}, // optional; tile was resized (w, h in grid cells)
onConfigChange(cfg) {} // optional; admin config was saved
};
}
root— the tile's body element. The module owns everything inside it and must never touch DOM outside it.- All four methods are optional, but a module without
stop()will leak timers when its tile is removed.stop()must leave nothing running.
moduleApi
| Member | What it is |
|---|---|
id |
Your module id. |
instanceId |
Unique per tile — two tiles of the same module get different ids. Handy for keying anything per-tile. |
variant |
For multi-tile modules: the id of the tile-list entry this tile was added as, '' otherwise (see Presenting multiple tiles). |
config |
Admin (server-wide) config, read-only, always current. Password fields are absent — they never reach the client. |
instanceSettings |
This tile's settings: your instanceSchema defaults overlaid with whatever this tile has saved. Read it fresh whenever you render — don't cache it. |
saveInstanceSettings(patch) |
Merge patch into this tile's settings and persist them with the layout. No restart happens — you made the change, you already know. Use it for state the user sets inside your tile (a toggled filter, a chosen tab). |
fetch(path, opts) |
fetch scoped to your server routes: fetch('/state') hits /api/modules/<id>/state. |
sse(path, handlers) |
An EventSource scoped the same way, with auto-reconnect (see below). Returns a handle with close(). |
setStatus(state, msg) |
Drives the tile's status dot: 'ok', 'connecting' or 'error', plus a hover message. Call it honestly — during a live service the dot is how an operator spots a dead feed. |
header.addButton(…) |
Put a small button in the tile's own title bar (see below). |
header.addMenu(…) |
Put a dropdown menu button there (see below). |
Title-bar controls
Modules don't build their own toolbars — actions live in the tile's title bar, next to the shell's gear/close buttons:
const btn = moduleApi.header.addButton({
icon: '<svg …>…</svg>', // inline SVG string, and/or
label: 'A+', // short text
title: 'Larger text', // hover tooltip
onClick() { … },
});
btn.classList.add('active'); // toggle styling for on/off buttons
const menu = moduleApi.header.addMenu({
label: 'Channels ▾',
title: 'Choose visible channels',
build(menuEl) {
// called on every open with an emptied menu element — fill it with
// buttons (give them your scoped classes; the container is styled and
// fixed-positioned by the shell, so the tile's overflow can't clip it)
},
});
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
Everything you add is removed automatically when your instance stops, so a remount never duplicates controls. Keep them compact (icon buttons, short labels): the title bar is 32 px tall and shared with the tile's name.
One more thing to know: the user can hide the whole title bar with the small notch on its bottom edge (a clean-view mode for wall displays). Your controls disappear with it, so nothing essential — status, reconnects, live data — may exist only as a header control.
sse(path, handlers) handlers:
const stream = moduleApi.sse('/stream', {
open(e) {}, // (re)connected
error(e) {}, // connection lost — say so via setStatus
message(e) {}, // unnamed "data:" events
events: { state(e) {} }, // named events by name
});
// later: stream.close()
Browsers' EventSource retries transient drops itself but gives up for good
when a retry gets a completed non-SSE response — which is what a proxy's 502
looks like while the upstream is down. The shell's sse() recreates closed
streams on a 3-second timer until they work again, so reconnect logic is
free: handle open (refresh/backfill your state; you may have missed
events) and error (report degraded status), and you're resilient. Streams
you forget to close are force-closed when the tile unmounts — but close your
own in stop() anyway.
Lifecycle you must expect
- Any number of instances. Two transcript tiles with different filters
is a feature. Keep all state inside
create()'s closure — no module-level mutable state inclient.js. - Remounts are routine. When the user edits the tile's gear settings the
shell calls
stop()and starts a fresh instance. When admin config changes, the shell calls youronConfigChange(cfg)if you export one, otherwise it remounts you. Either way you must come back cleanly. - The tile can be removed at any moment.
stop()is your only notice.
Rules for the client
-
Never touch DOM outside
root. Never define global CSS. Scope every rule in your stylesheet with the attribute the shell puts on your tiles:[data-module="my-module"] .my-thing { … }(Shadow DOM works too, if you prefer full isolation.)
-
Style with the shell's CSS variables — no hardcoded colors. The palette:
--bg(page),--panel(tile),--border,--text,--muted,--accent,--danger,--warn. Derive tints withcolor-mix(in srgb, var(--accent) 20%, transparent). This is what makes every module look native on the dark, high-contrast booth theme. -
No external network calls from the client. Browsers talk only to ProdDash. Anything upstream (your device, service, API) goes through your module's server routes — same reasoning as the reference apps: no CORS, no per-browser credentials, one place that owns the connection.
-
Handle upstream loss. Reconnect with backoff (or lean on
sse()), and report honestly viasetStatus. Tiles run unattended during live services; a module that silently freezes is worse than one that says it's down.
Server entry (server.js)
Optional — only needed when your module talks to something upstream or keeps
server-side state. CommonJS, Node built-ins only (keep the zero-dependency
ethos; Node 18+ so global fetch is available). Exports either or both of:
module.exports = {
init({ config, log }) {
// Called on startup / re-init, BEFORE routes(). Start your upstream
// connection here. Return a handle:
return {
stop() {}, // tear everything down
health() { return { status: 'ok', message: '' }; } // optional
};
},
routes({ config, log }) {
// Route table, mounted under /api/modules/<id>/
return {
'GET /state': (req, res) => { … },
'GET /stream': (req, res) => { … }, // SSE — see notes
'POST /command': (req, res) => { … },
'GET /proxy/*': (req, res) => { … }, // trailing /* = prefix route
'* /anything/*': (req, res) => { … }, // * method = any method
};
},
};
configis your admin config:configSchemadefaults overlaid with what the admin page saved — including password fields.log(...)prefixes output with your module id.- Handlers get plain Node
(req, res). On a prefix route,req.wildcardis the matched remainder (leading/included) andreq.searchis the query string (with?, or'') — so a proxy rebuilds the upstream path asreq.wildcard + req.search. - Exact routes match the path exactly; prefix routes (
/x/*) match/xand everything under it. First declared match wins. - Re-init instead of restart: when the admin page saves your config, the
shell calls your handle's
stop(), theninit()androutes()again with the new config. Keep module-level state insideinit()'s closure (or reset it there) so a re-init starts clean. Disabling a module unmounts its routes entirely (clients get 404). health()feeds the admin page's status line — report your upstream's reachability (status: 'ok' | 'connecting' | 'error'), not just "running".- A throw from
init()/routes()marks the module as failed: the error shows in/admin, and API calls answer 502 until a config fix re-inits it. Validate config early so bad input fails loudly there.
Presenting multiple tiles
By default a module is one entry in the + Add-tile picker. A module can instead present a list of tiles — one picker entry each, grouped under the module's name. This is how a module whose content is naturally plural works: one tile per ProPresenter timer, one tile per admin-configured page.
Export tiles from your server entry (sync or async):
module.exports = {
init({ config, log }) { … },
routes({ config, log }) { … },
tiles({ config, log }) {
// e.g. derived from admin config, or discovered from your upstream
return config.pages.map((page) => ({
id: page.name, // stable id, stored with the tile
name: page.name, // picker label AND the tile's title
description: page.url, // optional line under the label
settings: { page: page.name }, // preset instance settings
defaultSize: { w: 4, h: 3 }, // optional per-entry size overrides
minSize: { w: 2, h: 2 },
}));
},
};
tiles()runs on every picker load, so the list may be live data — keep it fast (answer from state you already hold; don't fetch upstream on demand). A throw or a hang (>2 s) falls back to the classic single entry, so a dead upstream can't break the picker.- When the user adds an entry, its
settingsoverlay yourinstanceSchemadefaults in that tile's instance settings, the entry'snamebecomes the tile's title, andmoduleApi.variantcarries itsid. - The tile keeps its variant id and title even if the entry later vanishes from your list (a timer deleted in ProPresenter, a page removed in admin). Your client decides what that means — show a clear "no longer configured" state rather than erroring.
- A client-only module can declare a fixed list as a
tilesarray inmodule.jsoninstead (same entry shape). - An empty list (or no
tilesat all) keeps today's behavior: one picker entry, no preset.
Server-Sent Events from a module
For live data, expose a /stream route and let clients use
moduleApi.sse('/stream', …):
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write('retry: 3000\n\n');
res.write(`event: state\ndata: ${JSON.stringify(latest)}\n\n`); // instant paint
streams.add(res);
req.on('close', () => streams.delete(res));
}
Broadcast only when the payload actually changed, send a : ping\n\n
comment every ~15 s so idle connections survive sleepy Wi-Fi, and wrap every
per-client res.write in try/catch (drop the client on failure). The
propresenter-now-next module is the reference implementation of this
pattern.
Complete minimal example
A working clock — copy-paste this as your starter. (A slightly fancier
version ships in modules/clock/.)
modules/hello-clock/module.json
{
"id": "hello-clock",
"name": "Hello Clock",
"version": "1.0.0",
"description": "Minimal example module",
"client": "client.js",
"style": "style.css",
"minSize": { "w": 2, "h": 1 },
"defaultSize": { "w": 3, "h": 2 },
"instanceSchema": {
"showSeconds": { "type": "boolean", "label": "Show seconds", "default": true }
}
}
modules/hello-clock/client.js
export default function create({ root, moduleApi }) {
let timer = null;
function tick() {
const { showSeconds } = moduleApi.instanceSettings;
root.querySelector('.hc-time').textContent = new Date().toLocaleTimeString(
[], { hour: '2-digit', minute: '2-digit', ...(showSeconds ? { second: '2-digit' } : {}) }
);
}
return {
start() {
root.innerHTML = '<div class="hc-time"></div>';
tick();
timer = setInterval(tick, 250);
moduleApi.setStatus('ok', 'Ticking');
},
stop() {
clearInterval(timer);
root.innerHTML = '';
},
};
}
modules/hello-clock/style.css
[data-module="hello-clock"] .hc-time {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.4em;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text);
}
Drop the folder in modules/, restart ProdDash, and “Hello Clock” appears
in the Add-tile picker (new modules are enabled by default; /admin can
disable them).
Testing your module
Run it:
node server.js # from the repo root — prints the LAN URL
The server logs each module it mounts (or why it refused to). /admin
shows mount errors and your health() line; the browser console shows
client-side failures (a tile that fails to start says so in the tile).
Poke the pieces directly:
GET /api/modules— what the shell sees (your manifest + non-secret config).GET /api/modules/<id>/...— your routes,curl-able. For SSE:curl -N http://localhost:24500/api/modules/<id>/stream.GET /api/admin/state— enabled flags, health, mount errors.
Mock upstreams (so you can develop without the real gear):
- ProdCom:
node tools/prodcom-mock.js(port 24480) — channels, groups, history, and a live stream with in-progress speech. Point the transcript module's ProdCom URL athttp://127.0.0.1:24480. - ProPresenter:
node band-lineup-display/tmp/pp-mock.js 1599— playlists and an advancing slide index (including the arrangement-repeat case). Point the Now/Next module at host127.0.0.1, port1599. - ProPresenter timers:
node tools/pp-timers-mock.js 1600— running, overrunning, stopped and elapsed timers plus an LTC timecode that periodically drops to no-signal (--no-ltcserves a ProPresenter without a timecode API). Also serves the stage-display websocket with an LTC-labeled layout field (--stage-pwd,--no-ltc-fieldexercise its failure paths). Point the Timers module at host127.0.0.1, port1600. - Planning Center:
node tools/pco-mock.js(port 24700) — folders, service types, plans (last week / today / next week), plan times and items with songs, keys and item notes, behind a Basic/Bearer auth check. Planning Center is a cloud API, so the module reads its base URL from the environment instead of admin config: run ProdDash withPCO_PLAN_API_BASE=http://127.0.0.1:24700 node server.js, enter any Application ID + Secret under PCO Plan in/admin, save, then pick "Sunday Service" from the Service type list. Its item titles matchpp-mock.js, so running both mocks exercises the ProPresenter matching.
The resilience checklist — every module must pass this before it's done:
- Add your tile, then kill the upstream (or the mock). The status dot must go red (with a useful hover message) and the tile must show a clear degraded state, not a freeze.
- Restart the upstream. The tile must recover by itself — no reload, no clicking.
- Restart ProdDash itself with the tile open. Same expectation.
- Add two tiles of your module with different per-tile settings — both must work independently.
- Remove your tile, then check the browser console and server log: no errors, no timers still firing, no streams still open.
- Change your admin config in
/adminwhile the tile is open — the tile must pick it up (viaonConfigChangeor the automatic remount). - Reload the page — the tile comes back with its settings intact.