UI components
Reference. Back to the architecture overview.
This codebase has no component framework. A component is a plain function that builds DOM elements and returns a handle. A small set of shared builders and one CSS token file keep the components consistent.
Read this guide before you add anything to src/ui/ or styles/. Almost
every widget pattern that you need already exists, and a copy built by hand
tends to miss the accessibility attributes that the shared builder sets.
Conventions states the policy behind these components: when to use a confirm dialog, how to style buttons, and what gets a toast. This guide covers the API.
docs/gallery.html shows every builder named here, rendered from the real
modules, beside the call that built it and the classes that the call
produces. The gallery loads ES modules, so open it through the dev server:
- Run
pnpm run dev. - Open
http://127.0.0.1:8080/docs/gallery.html.
To add a builder to the gallery, add a story to the matching file under
docs/gallery/sections/. The gallery reads the snippet from the source of
the story’s own render function, so no second copy of the call exists.
The builder contract is the normative part of this
guide: it says what any function in src/ui/ looks like. The rest of the
guide describes the builders that exist. Known gaps lists the
places where the code does not meet the contract.
The layers
src/app/*.js ............... mounts panels, owns callbacks and modals
|
v
src/ui/<Panel>.js .......... feature panels: build DOM, return { update }
|
v
src/ui/listPanel.js ........ the list-panel skeleton that most feature
| panels configure
v
src/ui/buttons.js .......... shared builders: buttons, icons, dialogs,
src/ui/icons.js form fields, tabs, disclosures, toasts
src/ui/Modal.js
src/ui/formFields.js, SpecForm.js
src/ui/Tabs.js, Disclosure.js, Toast.js, ContextMenu.js, dom.js
|
v
styles/base.css ............ design tokens and every shared primitive
A feature panel does not build its own button, empty state, dialog, or design value. It composes the layer below. The wiring layer above owns the data and the decisions (see The app wiring layer).
The builder contract
Everything in src/ui/ is a function that returns DOM, or a handle over
DOM. The rules in this section apply to a new builder and to every change to
an existing one.
Naming
| Form | Returns | Owns |
|---|---|---|
mount<Name>(container, callbacks) |
A handle, usually { update } |
Creates the root element and appends it to container |
build<Name>(spec) |
Detached DOM, sometimes with readers beside it | Nothing. The caller appends it and decides its lifetime |
wire<Name>(element, options?) |
A handle over markup that the caller already has | State and ARIA only. It builds no elements |
open<Name>(...) |
A Promise |
An element that comes and goes, such as a dialog or a menu. The promise resolves when the element closes |
noun(...) |
One element | A primitive with no lifecycle, such as iconButton, chip, icon, or textField |
A function that builds and mounts is a mount. A function that builds and
returns the result is a build. Name a builder after what it builds, not
after the feature that first used it.
Arguments
A builder takes at most two positional arguments, then one options object. A
value gets a positional slot only when it is required, has no default, and
reads clearly in order at every call site: iconButton(name, ariaLabel,
onClick, opts), chip(label, opts). Everything else is a named key in the
object.
- Options are optional. The parameter defaults to
{}, and every key has a defined behavior when it is absent. - One name means one thing everywhere.
className,variant,ariaLabel,title,placeholder, andonChangekeep their meaning across modules. - An existing option has one spelling. A per-variant boolean such as
{ danger: true }is not an alternative to{ variant: 'danger' }. - The second argument of a
mountis namedcallbacksand contains functions. A panel that names itoptions, takes a bare function, or takes four positional arguments differs from every other panel, and the caller has to read the source to mount it.
The className option
Every builder that returns an element accepts opts.className and appends
it to its own base class through classNames:
el('div', classNames(['chip', opts.className]))
- The option appends and never replaces, so a caller that passes a class
still gets the shared presentation. A builder that assigns
element.className = opts.classNamethrows away its own classes. - A space-separated string is valid.
classList.add(opts.className)throws on such a string, so combine classes throughclassNames. - Every builder that returns an element has the option. Without it, a caller that needs one extra class builds the element by hand.
Use className for layout in one feature and for one-off modifiers. Do not
use it for a state that the builder already knows about.
Semantic options
A caller says what it wants, and the builder owns the classes that produce it:
// yes
badge(label, { variant: 'danger' });
// no
el('span', 'badge badge--danger', label);
variant is the one name for this option. It maps to the block--<variant>
modifier of the block that the builder builds. The values are shared across
builders: primary, danger, success, and the neutral default when the
option is absent.
A block and its modifiers that exist in CSS with no builder to apply them are a missing builder, however few call sites type the class. Typing the class is the only choice those call sites have.
The vocabulary test
tests/uiVocabulary.test.js checks these rules. It reads src/ as text,
because the modules it covers build DOM and the test runner has no
document:
- A class that a builder owns is never typed by hand in another module.
The
OWNERStable in the test lists the owners. Call the builder and passclassNamefor your own modifier. The one recorded exception isDisclosure.js, which putssection-labelon a disclosure header. - A shared module names its own block and the utility layer, and nothing else. A shared widget therefore does not depend on the classes of one feature.
innerHTMLis only ever assigned''. Content is built withel.- A link (
<a>) is only ever built inbuttons.js. style.cssimports every sheet understyles/, and every import resolves.
A failure prints the file, the line, and the builder to call instead. If you
add a builder with a class of its own, add its block to OWNERS in the
test.
Settled decisions
elkeeps its positional class string.el(tag, className, ...children)is the leaf that everything else builds on. The nesting in the source reads as the nesting on the page, and an options object loses that.- The four update strategies stay. Full rebuild, the list panel’s guarded rebuild, the character sheet’s build-once-and-repoint, and in-place mutation each fit a different cost. One strategy for all of them makes the sheet slow or the small panels complex.
- The utility classes stay in the markup.
u-row,u-col,u-wrap,u-g1throughu-g4, andu-mutedare written at the call site, not folded into builder options. They style the space around and between elements, which the caller controls, and a builder owns only its own block. Agapoption on every builder puts the same token scale behind a second vocabulary. A builder can still apply a utility inside the element it owns:emptyStateaddsu-muted,labeledaddsu-col u-g1 u-muted,checkboxaddsu-muted, andbuildInlineFormaddsu-col u-g2. - Styles live in CSS files. The project has no CSS-in-JS and no
framework. A builder applies classes and has no style declarations of its
own. The source files run unbundled from any static server. esbuild
(
scripts/build.cjs) bundles them only forpnpm run devand the production build. - No component base class and no registry. A component is a function, so nothing registers and nothing extends a base.
Mount points
Every panel mounts into an element that already exists in index.html. Find
the element with mustGetElement(id) (src/ui/dom.js), not with
getElementById:
import { mustGetElement } from '../ui/dom.js';
const container = mustGetElement('encounter-container');
If the id is gone, mustGetElement throws
Required element #x is missing from index.html. Wiring runs at startup, so
a renamed id fails at load time. With getElementById, the panel silently
does not mount, and nobody notices until a GM clicks something.
DOM helpers
src/ui/dom.js has the element primitives that every widget builds from:
| Function | Does |
|---|---|
el(tag, className?, ...children) |
Builds an element with a class and children. A string child becomes a text node, and a null, undefined, or false child is skipped, so cond && node works inline |
append(node, children) |
Appends children with the same rules as el |
classNames(parts) |
Joins the truthy parts of a class list |
setAttrs(node, attrs) |
Sets several attributes |
mustGetElement(id) |
Finds a mount point, or throws |
uniqueId(prefix) |
Returns an id that is unique in the document, for aria-labelledby and aria-describedby |
The panel contract
A feature panel is a mount<Name>(container, callbacks) function. It
creates its own root element, appends the root to the container, draws once,
and returns a handle:
export function mountQuestPanel(container, callbacks) {
const root = document.createElement('div');
root.className = 'quest-panel';
container.appendChild(root);
function render() {
root.innerHTML = '';
// ... build rows from callbacks.getQuests()
}
render();
return { update: render };
}
This example shows the pattern written by hand. Most list panels, including
the real mountQuestPanel, get it from mountListPanel (see
The list panel) and do not write this code.
The { update } handle is the Updatable interface (src/types/app.ts),
and it is the whole refresh protocol between modules. A wiring module stores
the handle on app.views.questPanel. Anything that changes a quest calls
app.views.questPanel.update(), and does not need to know what the panel
draws.
An update() call needs no knowledge of the panel, because every panel
follows these rules:
- Panels keep no campaign data of their own. A panel draws from
get*callbacks that it calls at render time (getQuests(),getRole()), soupdate()always reads the current state. A panel that caches campaign data needs a second way to mark the cache stale, which the singleupdate()entry point exists to avoid. View state that is not campaign data belongs to the panel: which row is in edit mode, which section is open, which tab shows. For example,LibraryPanel’supdatecloses an open inline editor, but keeps its filter text and selected subtab. The input and the tab strip are built once, and only the lists redraw. The close also puts back the focused control and the scroll offsets from when the editor opened, because hiding the list resets them to the top. - Every mutation leaves through a callback. Panels do not write state
and do not open dialogs. A panel calls
callbacks.onEdit(id), and the wiring module prompts, writes, and redraws. Panels then contain only DOM code, so you can check them by eye and need no mocks. -
Role gating is also a callback. A panel that shows GM-only controls takes an optional
getRole, and shows the GM view when it is absent:const gmView = () => !callbacks.getRole || isGM(callbacks.getRole());In the Player view, the panel does not build edit, delete, or add controls at all.
By default, render() clears innerHTML and rebuilds. This fits the small
lists that most panels show. A panel built on mountListPanel also skips
the rebuild when nothing changed: it compares the rows it is about to draw
with the rows it last drew, and returns early when they are the same
objects. A party step can then fire five panel refreshes with no rebuild.
The travelogue, the tile inspector, and the character sheet avoid the rebuild in their own ways. The travelogue compares anchor ids, the tile inspector builds once and points its nodes at new values, and the character sheet does the same behind a structure check (see below). Copy one of these patterns only for a panel that is large or that grows without limit. See Conventions.
The character sheet’s structure check
The sheet is the one panel where a full rebuild costs enough to design around. A rebuild on one HP tick discards about two hundred elements (six ability badges, each with an inline SVG die, every spell-slot pip, the progression and spell sections, and the condition chips) to move the width of one bar. Every tick also commits, which refreshes the sibling panels too.
The sheet therefore splits its work. build() creates the DOM once and
collects a list of small writers, and each writer puts one current value
into an element that it captured. render() runs only those writers when it
can.
sheetDeps(character, perms) in src/view/SheetStructure.js decides whether
render() can skip the rebuild. It returns a flat list of every value that
the structure of the DOM depends on, and sameDeps compares it with the
last list:
- When the lists match, the only differences are values that a writer can write (a pool level, bonus HP, base AC, the name, the conditions), so the DOM stays.
- Any other change triggers a rebuild: a class taken, an ability improved, an item equipped, a pool added or resized, or a permission change.
The repoint path puts two limits on how you extend the sheet:
- Event handlers read the live character, not the character they were
built from. A handler outlives the change that follows it, so the sheet
passes a
live()getter around, andbuildProgressSectiontakes a getter and not a character. A handler that closes over a build-time snapshot silently undoes every change written since. sheetDepsnames every field that the structural builders read. If you add a read to the sheet, the progression section, or the spell section without an entry insheetDeps, the display goes stale, and the typecheck cannot catch the error.
This comparison by reference works because the entity layer never changes data in place (see Conventions).
Handles that are not { update }
| Handle | Used by | Why |
|---|---|---|
{ setCharacter }, plus getCharacter on the sheet and the inventory |
CharacterSheet, InventoryPanel, SpellbookPanel |
These three panels are scoped to one selected character, which they keep and draw from. A sibling panel’s edit arrives through setCharacter |
| A domain handle | segSwitch ({ element, getValue, setValue, sync }), ThemeToggle ({ getTheme }), PalettePanel ({ getBrush, getScale, setKind, show, regionPicker }), TileInspector ({ setTile }), mountToasts ({ show }) |
A control and not a list, so there is nothing to redraw from state |
DOM plus readers from build<X>Form(...) |
buildItemForm, buildSpellForm, buildFeatForm, buildCreatureTemplateForm |
An inline form is built for one edit and then discarded, so it is built and not mounted |
Promise<result> |
combatSetupModal, generateDialog, promptSpellDetail, and every dialog in Modal.js |
A dialog asks one question and gets one answer |
{ element, get, set } |
buildDamageEditor, buildEffectsEditor (ItemFormEditors.js) |
A composite widget inside a form. It keeps a working copy, gives element to mount, get to read at submit, and set so that a preset picker can overwrite it |
To add a composite form widget, use the last contract. ItemForm then
treats a damage-parts editor exactly like a text input.
The list panel
Most rails show the same layout: a list of entities, a row for each entity
with its buttons, an empty state, and a “New …” control at one end.
mountListPanel(container, options) (src/ui/listPanel.js) builds this
layout. The quest, handout, NPC, encounter, library, and Build-rail
encounter panels each configure it, and it returns the usual { update }.
A panel with tabs mounts one list panel for each tab panel. The encounter
panel’s Active tab is a list panel. Its Nearby tab is NearbyList.js,
which draws one disclosure line per group of foes from
view/NearbyGroups.js and mounts a list panel for the open group only.
The equipment library’s five category subtabs are five list panels.
The caller decides the markup:
| Option | What it does |
|---|---|
className |
The root element’s class, and the stem of the row class (quest-panel gives quest-panel__row) |
classes |
Every class below the root, in one object (see the next table) |
getRows(gm) |
The entities to draw, already scoped and ordered |
buildBody(entry, ctx) |
The row’s content, left of the buttons: one node or an array |
actions(entry, ctx) |
The row’s buttons, as { icon, label, variant, onClick } descriptors. null entries are dropped, so an optional control is a ternary |
buildExtras(entry, row, ctx) |
Anything below the row’s head, such as a stat bar or a read-aloud body |
emptyMessage |
The empty-state text |
groupOf(entry, gm) |
An optional section heading, shown when consecutive rows change group. A null group after a grouped row ends the group: the rows that follow go back to the root with no heading, and a later named group starts a new heading |
addButtons(gm) |
The add controls |
addPlacement |
Where the add controls go: at the end of the list (inline, the default), leading the list in a pinned .panel-actions row (leading), or trailing it in a plain row (trailing) |
gate() |
false for the read-only Player view, which shows no action buttons and no add controls |
dependsOn() |
One comparable value for anything the panel draws that its rows do not describe (see below) |
The classes object keeps the option list about behavior. Each entry is
optional. An omitted entry falls back to the stem, or drops the wrapper that
it names.
classes entry |
What it does |
|---|---|
row |
The row’s class when it cannot come from the stem (a list nested in a wider panel names its rows after the outer panel) |
rowModifiers(entry, gm) |
Extra classes on one row, such as for a completed quest or a defeated foe |
body, actions, head |
Whether the body nodes, the buttons, and the pair of them get wrapper elements, and their classes |
group, groupHeading |
The element that collects one group’s rows, and a class added to the group heading, which is always a section-label |
add |
The class on each add button, unless the button names its own. leading placement ignores it, because its pinned row styles the buttons |
The helper owns the root element, the clear and rebuild, the row loop, the
group headings, and the handler contract below. It also keeps keyboard
focus across a rebuild: captureFocus and restoreFocus
(src/ui/focusMemory.js) find the rebuilt control by its tag, type, class,
and accessible name. A root that scrolls itself, such as the quest log
below the Play shell width, keeps its scroll position across a rebuild too.
A root that grows with its content, such as the quest log in its sidebar
tab, keeps its old height until the rebuild ends, so the scrolling tab
panel around it does not clamp its scroll position while the root is
empty. Without either, the clear scrolls the list back to the top, and the
row under the pointer changes.
The helper awaits every handler, and redraws unless the handler reports
that nothing happened. A handler reports this by returning false or
null, which is what a cancelled dialog gives: confirmModal resolves
false and promptModal resolves null. A handler that returns nothing
counts as a change, and the panel redraws. A button that you write by hand
like this:
const button = iconButton('edit', `Edit ${quest.title}`, async () => {
if (await callbacks.onEdit(quest)) render();
});
becomes this descriptor:
actions: (quest) => [
{ icon: 'edit', label: `Edit ${quest.title}`, onClick: () => callbacks.onEdit(quest) },
],
The ctx given to buildBody, actions, and buildExtras contains:
gm, the resolved gaterender, for a custom control that refreshes the listaction(spec, entry), which builds a button with the same handler contract
A leading toggle, such as the quest’s complete button or the handout’s eye,
uses action even though it sits inside the body and not in actions.
The early return in update() compares row objects by identity, which
works because the entity layer never changes data in place. The Active
encounter tab uses dependsOn for its Start combat button, which appears
and disappears with whether a fight is running. The guard compares that
value with Object.is, beside the rows and the gate. repaintNeeded(last,
next) is the decision itself, exported so that tests/listPanel.test.js
can test it with no DOM.
Build the dependsOn value from numbers, strings, or booleans. A fresh
object on each read differs from the last one every time. That turns the
guard off and also discards what the user typed into a row’s input on every
refresh.
Buttons, icons, and empty states
src/ui/buttons.js
src/ui/buttons.js has the button and link builders, a segmented switch,
an empty-state paragraph, the chip pair, a status badge, and the section
label:
iconButton(name, ariaLabel, onClick, opts?) -> HTMLButtonElement
textButton(label, onClick, opts?) -> HTMLButtonElement
bareButton(children, onClick?, opts?) -> HTMLButtonElement
iconLink(name, ariaLabel, href, opts?) -> HTMLAnchorElement
textLink(label, href, opts?) -> HTMLAnchorElement
segSwitch({ ariaLabel, options, value, onChange, className? })
-> { element, getValue, setValue, sync }
emptyState(message) -> HTMLParagraphElement
chip(label, opts?) -> HTMLElement
removableChip(label, onRemove, opts?) -> HTMLElement
badge(label, opts?) -> HTMLSpanElement
sectionLabel(text, opts?) -> HTMLElement
A panel does not create a <button> element itself:
- A control with the
btnpresentation is aniconButtonor atextButton. - A control that acts as a button for the keyboard but has no button
presentation is a
bareButton, whatever class it then takes. Examples are a tab, a menu item, a tree row, a disclosure header, a spell-slot pip, and a target card.
The only <button> elements written by hand are the static ones in
index.html (the sixteen tabs, the header file controls, the sidebar
toggle, and the Build and Library mode controls) and the palette swatch,
which is an image tile.
iconButton builds btn btn--icon. It requires an ariaLabel, because an
icon-only button has no other accessible name, and it uses that label as the
default tooltip. Pass opts.title only for a tooltip shorter than the
label.
textButton builds btn with an optional leading opts.icon. The visible
text is already the accessible name, so the builder sets ariaLabel only
when the caller passes one. Pass one when the label alone is unclear, such
as a weapon name whose action is “Attack with …”. A dialog’s confirm
button passes opts.type: 'submit' with an opts.value, which the dialog
reads back from returnValue. An Escape dismissal leaves that value empty,
so the dialog can tell it from a confirm. Such a button needs no onClick,
so the argument is optional.
bareButton builds btn-bare, a reset that strips the browser’s button
presentation. The look comes from opts.className, and the children are the
button’s content, so an icon and a label can nest with no second builder.
onClick is optional, for a button that another helper wires:
buildDisclosure builds its header this way. A control whose visible
content is not its accessible name passes opts.ariaLabel.
iconLink and textLink build the same btn look on an <a href>, for a
control that opens a URL. A real link keeps the browser’s middle-click,
modifier-click, and “Open in new tab” menu, and a <button> that sets
location has none of them. The label and tooltip rules match iconButton
and textButton. opts.newTab adds target="_blank" with rel="noopener",
so the new page gets no handle on the app. .btn sets
text-decoration: none, so the link has no underline. The Party roster uses
both builders: an iconLink on each row opens a player tab for that
character, and a textLink below the list opens a spectator tab.
Both btn builders take opts.variant, which maps to a btn--* CSS
modifier:
variant |
Result |
|---|---|
| (omitted) | Neutral outlined button |
'primary' |
Accent-filled: the affirmative action of a form or dialog |
'danger' |
Danger-outlined, fills red on hover |
'success' |
Success-outlined, fills green on hover |
Every destructive control passes variant: 'danger', stays visible (it does
not appear only on hover), and asks for confirmation first. See
Conventions.
segSwitch builds a role="group" of buttons over one value. The header’s
mode, role, and theme switches and the dice tray’s d20 mode use it. Each
option is { value, label?, icon?, ariaLabel?, title? }, so a choice can be
text, an icon, or both. The selected button gets the active class and
aria-pressed together. The caller appends element itself, so the dice
tray can put the switch inside a labelled row.
setValue selects a choice and reports it through onChange, the same path
that a click takes. The mode switch calls it right after mounting, to apply
the starting mode’s body classes. sync repaints the buttons and reports
nothing. Use it when the value lives elsewhere and can change without the
switch, as the dice tray’s selection object does.
emptyState(message) is the one <p class="empty-state">, and every list
panel’s “nothing here yet” line uses it. The class sets only the margin, the
padding, and the italic style. The builder adds u-muted, which supplies
the muted color and the label size.
chip(label) is a <span class="chip"> with the label in its own inner
span, so a caller can append to the chip without disturbing the text. With
opts.onClick it is a <button class="btn-bare chip"> instead, which the
stat-block chips use to open their editor. The option decides the tag, so a
chip that looks clickable is always a button.
removableChip(label, onRemove) adds the trailing x (.chip__remove) that
calls onRemove. Pass opts.removeLabel when the visible label is not the
name of what the x removes: the conditions bar shows “Poisoned (3)”, and its
button reads “Remove Poisoned”. Status conditions, the effects that a weapon
inflicts, and tag-field pills all use these two builders. opts.className
takes a modifier for one feature.
badge(label) is the read-only marker on a list row. opts.variant covers
the three shared readings (success, danger, and neutral), which color
an NPC’s disposition. A marker that means something outside that scale
passes opts.className for its own color: a prepared spell takes the mana
color, and a custom library entry takes the accent.
sectionLabel(text) is the sub-heading inside a panel section. It is a
span by default, because it labels the box beside it and does not open a
section. opts.tag makes it an h3 or h4 for a heading that the document
outline gives to a screen reader.
src/ui/icons.js
icon(name, { size = 18, className }?) -> SVGSVGElement
icon builds an SVG from a table of 24x24 stroke path data (PATHS).
Icons draw in currentColor, so they take the color of the button or text
around them and follow the theme with no extra work. The builder uses
createElementNS with path strings, never innerHTML.
Every icon is aria-hidden="true". Icons here are decorative, and the
control around them owns the accessible name. iconButton therefore
requires a label.
These are the 40 names in IconName:
plus minus heal remove edit save export import dice d20 add
check chevron circle box boxChecked map fit target sword shield
clock flag scroll sparkles eye eye-off lock give sun moon
monitor warning external up down more minimap revert pointer
An unknown name gives an empty SVG and no error, so a typo shows as a blank
gap. The typecheck catches the typo first, because IconName is a union of
string literals.
minus and heal are the fixed pair for HP that goes down and up.
sword marks attacks and foes, and never marks damage (see
Conventions). To add a glyph, add its path data
to PATHS and its name to IconName. Do not put an inline SVG at the call
site.
Stat bars
buildStatBar(pool, { modifier, label, critical?, bonus?, showLabel?, compact?,
band?, hero?, className? }) -> { element, update(pool, bonus) }
buildStatBar in src/ui/CharacterBars.js draws a pool such as HP as a
filled track. The full form is one wide line with a label, the track, and
the numbers, for the character sheet’s head:
showLabel: falsekeeps the numbers beside the track, for a combat board card.compactputs the numbers over a fixed-width track, for a roster row.bandcolors the whole fill by the remaining fraction, in three steps.herowraps the bar onto two lines, with the label and the numbers over a tall track the full width of its box. The character sheet uses it for HP and puts the damage and heal buttons on a row under it.
The label still names the bar to a screen reader in every form. update
rewrites the fill, the numbers, and the label for a new value of the same
pool, so an HP tick changes four properties and rebuilds nothing.
Fact lines
factLine(label, value, { layout = 'stack', className }?) -> HTMLDivElement
src/ui/FactLine.js draws a label with the value that it names. The
initiative, AC, and HP of the active combatant (CombatActiveColumn.js),
the casting details in SpellDetail.js, and the lines of LoadoutBlock.js
all use it, so one class family covers every line of this kind.
The label is a sectionLabel, so every such line has the same case and
size. The value takes any Child, so a caller can pass a built node where
plain text is not enough: the detailed loadout passes a row of slot chips.
layout: 'row' puts the value beside the label, not under it. Use it for a
block of lines that reads as a table, and give the label a width in your own
sheet so that the values line up. The loadout block does both.
A fact line draws text. For a fraction drawn as a filled track, use
buildStatBar.
Death-save block
deathSaveBlock(state, { name, canAct, onRoll, onStabilize }) -> HTMLElement | null
src/ui/DeathSaveBlock.js draws the line that a character at 0 HP shows:
three success pips, three failure pips, and the Roll and Stabilize controls.
A stable character reads “Stable at 0 HP”, and a dead one reads “Dead”, with
no controls. The combat screen’s active column and the character sheet both
call it, so the two cannot describe the same state differently.
The function returns null when the character has no tracker, which is the
usual case. A caller appends whatever comes back and tests nothing itself.
canAct gates the controls. On the combat screen it is row.mayAct, and on
the sheet it is the play permission.
The words and the pip counts come from view/DeathSaveView.js, which is
pure and unit tested. This module is the DOM around them.
Exhaustion bar
mountExhaustionBar(container, { getEntity, onSet, canEdit }) -> { update }
src/ui/ExhaustionBar.js draws the exhaustion row: a label and one pip for
each level, filled up to the current level. The character sheet, the NPC
panel, and the Encounters panel mount this row, so all three read the same.
A click on a pip sets the level to that pip. A click on the pip of the current level removes one level. Each pip is a button with its own accessible name, so the row works from the keyboard.
onSet reports the new level, and the owner writes it. The write always
goes through app/exhaustion.js, because the sixth level kills the
combatant and writes a log line.
Without canEdit, the row shows plain glyphs and no controls, and it is
hidden at level 0 because a spectator has nothing to read there. The
character sheet passes the GM-only restore permission as canEdit, so a
bound player can read the row but not click it. The NPC and Encounters
panels mount the row on GM rows only.
The words and the pips come from view/ExhaustionView.js, which is pure and
unit tested. This module is the DOM around them.
Dialogs
src/ui/Modal.js wraps the native <dialog> element. Each entry point
returns a promise:
promptModal(title, fields, options?) -> Promise<Record<string, string> | null>
alertModal(message, options?) -> Promise<void>
confirmModal(message, options?) -> Promise<boolean>
confirmDelete(name, detail?) -> Promise<boolean>
Every dialog in the app shares one lifecycle:
- Capture
document.activeElementas the opener. - Build and append the dialog.
- Call
showModal(). - On
close, remove the dialog and put focus back, then resolve.
Escape closes the dialog, because <dialog> does that natively. Nothing
here implements an overlay, a scrim, or a focus trap again.
Every dialog has a name. The title gets a unique id, and the dialog points
at it with aria-labelledby, so a screen reader announces the title and
not only “dialog”. confirmModal defaults its title to the confirm label as a
question, such as “End combat?”, or to “Confirm” when it has no label.
confirmDelete titles its dialog with the name, as in “Delete Wren Tallowby?”.
alertModal defaults its title to “Notice”. Both point aria-describedby at
the message.
A danger confirm opens with focus on Cancel, so a stray Enter cannot delete
or replace anything. confirmModal takes confirmLabel for the confirm
button and cancelLabel when the dismiss button does more than close, such
as “Keep saving paused”.
On close, focus goes to the caller’s returnFocus element when the caller
names one. A caller names it when the element that owns the interaction is
not always the element that had focus, such as a button that the browser
does not focus on click. Otherwise focus goes back to the opener. A dialog
often removes or rebuilds the control that opened it, so when neither
element is still in the document, focus goes to the <main> landmark.
pickReturnFocus in src/ui/dialogFocus.js makes this choice. It is a pure
function with its own tests.
The lifecycle itself is openDialog, also exported from Modal.js. Use it
for a dialog that is not a form, a message, or a question:
openDialog({ className, title, form, returnFocus, build, result }) -> Promise<T>
build(close)returns{ body, actions, initialFocus, description }: the content between the title and the button row, the buttons (wired to theclose(value)that they receive), the element that takes focus on open, and the element that describes the dialog, if any.resultmaps the dialog’s return value to the value that the promise resolves to. It runs while the dialog is still mounted, so it can read the dialog’s inputs. It can return a promise when the value is not settled yet, which is how the dialog awaits the file field’s decode.- With
form: true, the parts go inside a<form method="dialog">, so Enter submits, and a submit button’svaluebecomes the return value.
Five dialogs live outside Modal.js, and all of them use openDialog:
promptSpellDetail (SpellDetail.js), combatSetupModal
(CombatSetup.js), generateDialog (GenerateDialog.js), the
ability-score breakdown (CharacterStatBadge.js), and choiceModal
(ChoiceModal.js). choiceModal(message, choices, { title, checkLabel })
shows one button for each choice beside Cancel, with an optional checkbox
under the message. It resolves with { choice, checked }, and choice is
“cancel” for Cancel or Escape. Focus return and dismissal
have one owner, so these dialogs behave the same as the ones in Modal.js.
Conventions says which dialog to use:
confirmModal only for a question with two real answers, choiceModal for
a question with three or more, alertModal for a
blocking notification, app.toasts.show for one that dismisses itself, and
confirmDelete(name, detail?) for a plain entity delete. confirmDelete
owns the Delete "X"? wording and the danger button, so no call site
restates them.
promptModal fields
A field is a ModalField record (src/types/modal.ts), and type picks
the widget:
type |
Widget | Value in the result record |
|---|---|---|
'text' (default) |
input.field |
The string |
'search' |
input.field with type=search. Enter in it does not submit the dialog, because it narrows another field. The rail form has no renderer for it |
The string |
'number' |
input.field with type=number, honoring min and max |
The string. An out-of-range value moves to the nearer bound on change, not on each keystroke |
'textarea' |
textarea.field, rows lines tall |
The string |
'checkbox' |
One on/off box | '1' when checked, '' when not |
'select' |
select.field over options: { value, label, disabled? }[] |
The selected value |
'file' |
Image picker | A data: URL from readImageFile |
'multiselect' |
A scrollable checkbox group, capped by max. columns lays a short list out in columns with no scroll, fixedHeight stops a refilter from resizing the dialog, and emptyText fills an empty list |
The checked values, joined by commas |
'tags' |
A pill list with an inline entry | The pills plus any unfinished text, joined by commas |
'pillgrid' |
An assignment grid of rows by options, one option for each row |
row:value pairs, joined by commas |
'allocation' |
A distribution grid with a number input for each row, whose values add up to total. unit names what the rows count, such as “rays” |
row:count pairs, joined by commas. A row given 0 is left out |
'button' |
An action button inside the form | ''. It acts through onChange |
'note' |
A line of warning text with no input, a polite live region. onChange rewrites it with setLabel |
'' |
Every field also takes name, label, value, full, newRow, hidden,
disabled, advanced, and section. The text, number, and textarea fields take
placeholder.
- With
options.wide, the form lays out two fields in each row.full: truespans both columns, andnewRow: truestarts a row.newRowkeeps a pair that belongs together (such as weapon and armor) on one row when an odd number of fields comes before it. - A field with
sectiongets asection-labelheading of that text above it, across both columns, with themodal__sectionclass. The creature dialog and the Library template form group their fields this way. In the dialog, an advanced field shows no heading, because the disclosure summary names it. The Library form (buildSpecForm) has no disclosure, so it shows the advanced fields open under their own section heading. options.submitRequiresnames checkbox and text fields. The submit button stays disabled until each named checkbox is ticked and each named text field has text, and a named text field getsaria-required.- A field marked
advancedgoes into one collapsed<details>block, captioned byoptions.advancedLabel(default “More options”). The block sits where the first advanced field appears, so a plain submit does not make the GM read past situational inputs. - The buttons are the dismiss button (
options.cancelLabel, default'Cancel'), then the submit button (options.submitLabel, default'Create'). Every form in the app puts the dismiss button on the left and the primary action on the right. Only the submit button, or Enter in a field, resolves the values. The dismiss button and Escape resolvenull. options.messageputs a paragraph above the fields, and the dialog pointsaria-describedbyat it. Use it for a long prompt, and keep the field caption short.- Dialog text wraps at
--modal-measure(30rem). A long message, caption, or select stops at that width, so it does not stretch the dialog to 90vw.
A dialog rebuilt by hand tends to lose these behaviors:
- The file field shows errors inline, as a
<p class="modal__error" role="alert">inside the dialog, not as a second alert dialog. It clears the input after a failure, so picking the same file again fireschangeagain. The dialog awaits reads in progress before it collects the result, so a fast submit cannot drop the image. onChangegets a handle on the whole form, so one field can drive another (a tier select that stamps default stats again, or a class select that filters a spell list). The handle is{ get, set, setOptions, setDisabled, setLabel, setRange, setHidden, setTotal, suspend }, all keyed by field name.getis always synchronous, andsetTotalapplies to allocation fields only.suspend(name, work)closes the dialog whileworkruns, then opens it again with every value kept and focus on the fieldname. The “Pick on map” button uses it, because the page behind a modal dialog takes no clicks.buildSpecFormhas nosuspend.
The composite fields (multiselect, tags, pillgrid, allocation) keep
their own local state and redraw themselves. A refilter through
setOptions keeps the current selections, even the ones that leave the
option set.
The allocation field is the one field that can block a submit. It sets a
message on its first input through setCustomValidity whenever the rows do
not add up to total. The browser then refuses the submit and reports the
error as it does for any other invalid field. The rows scroll, but the line
with the remaining count does not, so the reason for a refused submit stays
in view.
Inline forms
The Library rail’s authoring forms render inline, not in a dialog, so they
build from src/ui/formFields.js and not from Modal.js:
labeled(caption, control, opts?) -> HTMLElement // captions the control
captioned(caption, control, className) -> HTMLElement // the wrapper under labeled
fieldRow(...children) -> HTMLDivElement // one horizontal group
checkbox(caption, checked, opts?) -> { label, input }
checkboxInput(checked) -> HTMLInputElement // the bare box
textField(value, opts?) -> HTMLInputElement
numberField(value, opts?) -> HTMLInputElement
textareaField(value, opts?) -> HTMLTextAreaElement
select(options, value, opts?) -> HTMLSelectElement
setOptions(select, options, value) // refill an existing picker
buildInlineForm({ nameInput, rows, assemble, submitLabel, onSubmit,
onCancel?, afterSubmit?, className? }) -> HTMLDivElement
labeled picks its wrapper through captioned and captionWrapperKind:
- A single labelable control (an input, select, textarea, or button) goes
inside a
<label>. The caption is then the control’s name, a click on the caption focuses it, and nofor/idpair needs to stay in step. - Anything else, such as a checkbox grid or a pill grid, goes inside a
<div role="group">that the caption names througharia-labelledby. A<label>around such a group names only its first box, with the whole group’s text.
labeled, checkbox, and the field builders take the shared options
className and ariaLabel. select accepts bare strings (the value is the
label) or { value, label } pairs, so the same helper serves enum pickers
and labelled choices.
These controls are not only for the rail. promptModal builds a dialog’s
plain text, number, select, and checkbox fields from the same functions, so
a field behaves the same in a dialog and in the rail. numberField owns the
range correction on change for both. It reads min and max from the
element, so a dialog that changes a field’s range through setRange still
gets the correction.
buildInlineForm is the frame that the item, spell, feat, and spec forms
share. It wraps the form, puts the name field first with the shared
form__wide sizing, appends rows in order, and ends with the button row
(Cancel left of the primary submit, as in the dialogs).
- A submit reads the whole form through
assemble, which returns the finished value, ornullto refuse the submit. - The frame refuses a blank name before
assembleruns, so no form checks it again. afterSubmitruns after an accepted submit. The inventory’s add row uses it to clear itself, and the editor for one item keeps its values on screen.
An assemble function reads its controls and passes the values to a pure
function, and does not build the finished value itself. The meaning of each
value then stays testable without a DOM. The item and spell forms do this
through entities/ItemDraft.js and entities/SpellDraft.js, which have the
tests. See Entities.
Spec forms
The creature template form builds no controls of its own. An entity that
the GM authors both in a dialog and in the rail describes its fields once,
as the ModalField[] that promptModal takes, and src/ui/SpecForm.js
renders the same list inline:
buildSpecForm({ fields, assemble, submitLabel, onSubmit,
onCancel?, onChange?, className? }) -> HTMLDivElement
- The first field is the entity’s name and becomes the wide name input. The
other fields go two to a row, honoring
fullandnewRowas the wide dialog does. assemblereceives the same record of field names to strings thatpromptModalresolves to, so both read a form back through the same functions.onChangereceives the sameModalFormHandle, so a rule such as “stamp the default stats again when the tier changes” runs in both. The handle’ssetTotaldoes nothing here.
The spec itself is in app/creatureFields.js (see
The app wiring layer). The controls come from
formFields.js and ModalFields.js, the builders that the dialog uses. The
file, tags, pill-grid, allocation, and button kinds have no inline renderer.
A spec that uses one of them throws, so the form never silently drops a
field.
Tabs and disclosures
Disclosures and most tab strips are wired over existing markup, not built. The caller owns the elements, and the helper owns the state and the ARIA.
wireTabs(tablist, { resolvePanel?, onSelect? }?) -> { select(tabId) }
src/ui/Tabs.js implements the ARIA tabs pattern over a [role=tablist] of
[role=tab] buttons. Each tab points at its [role=tabpanel] through
aria-controls. The helper keeps aria-selected, a roving tabIndex (only
the active tab is in the document tab order), and panel.hidden in step.
- Arrow Left and Arrow Right wrap around, and Home and End jump to the ends. Each key moves focus.
- A click selects a tab without moving focus.
- The first selected tab is the one already marked
aria-selected="true"in the markup, or the first tab.
Most strips are written in index.html, so the caller only wires them. When
the tabs are known only at runtime, buildTabs builds the strip:
buildTabs({ ariaLabel, className?, tabs: [{ id, label, panel }], selected?, onSelect? })
-> { tablist, select(id) }
buildTabs creates the buttons, generates the id pairs that
aria-controls needs, marks up the panels that the caller passed, and gives
the result to wireTabs. resolvePanel finds the panels before they are in
the document. onSelect reports the caller’s own tab id, including for the
first selection. The encounter panel’s two tabs and the equipment library’s
category subtabs use this path.
Selecting a tab only flips hidden, so the panels’ contents stay in the DOM
across a tab click and refresh on their own schedule. Neither panel redraws
to move a highlight. Use one of these two helpers for every tab strip.
buildDisclosure({ body, label?, headChildren?, className?, ariaLabel?, expanded?, onToggle? })
-> { head, body, isExpanded, setExpanded }
wireDisclosure(button, body, { expanded?, onToggle? }?) -> { isExpanded, setExpanded }
src/ui/Disclosure.js keeps aria-expanded on the button, toggles the
disclosure--open class (which rotates the chevron through CSS), and sets
body.hidden. setExpanded runs once when the helper wires the button, so
onToggle also fires at that time. A panel that redraws rebuilds its DOM,
so pass the last known state as expanded, and record changes from
onToggle.
buildDisclosure also builds the header. The header is a bareButton with
the disclosure class and the chevron. With a label, it also gets
section-label, the shared group-heading treatment. A header with its own
look leaves out label and builds its content in headChildren, as the dice
tray does with a d20 icon and the text “Roll dice”. A header with no visible
text names itself through ariaLabel. Anything between the label and the
chevron, such as an item count, goes in headChildren. The header and the
body come back as siblings, so a panel can put them in whatever box its
layout needs. Use wireDisclosure directly only for a header that the
caller builds itself.
Keyboard grids
rovingTarget(index, key, count, columns) -> number | null
columnsFromTops(tops) -> number
src/ui/rovingIndex.js is the pure half of a roving tabindex over a grid
of buttons. A grid of many small controls, such as the tile swatches of the
Build palette, keeps one Tab stop and moves focus inside itself with the
arrow keys. rovingTarget gives the index that a key moves to:
- Left and Right step one item and wrap at the ends of the list.
- Up and Down step one row and stop at the first and last row, so a held key does not loop through the grid.
- Home and End go to the first and last item.
- Any other key returns null, so the browser handles it.
columnsFromTops counts the items in the first rendered row from each
item’s offsetTop. PalettePanel.js applies the result to its elements.
Tabs.js keeps its own version, because a tab strip also selects on an
arrow key.
Toasts
mountToasts(container, { duration = 3500 }?) -> { show(message, { level? }?) }
queueToastAfterReload(message)
flushQueuedToast(toasts)
main.js mounts one toast stack from src/ui/Toast.js on document.body
and puts the handle on the context as app.toasts, so no other module
imports Toast.js. The stack has two live regions:
level |
Region | Behavior |
|---|---|---|
'status' (default) |
role="status", aria-live="polite" |
A screen reader announces it without interrupting. It dismisses itself after duration |
'error' |
role="alert", aria-live="assertive" |
A screen reader announces it at once. It stays four times as long and has a Dismiss button for keyboard users |
A click on any toast dismisses it early.
The queue functions carry one confirmation across a page reload through
sessionStorage. Use them for an action that completes after the current
document is gone, such as Undo, Import, or New.
Toasts render over map art, so they use the --overlay-* tokens and not the
page background colors (see Tokens).
Tooltips
mountTooltips(container) -> { hide }
setTip(element, text) -> HTMLElement
tipPlacement(anchorRect, tipSize, viewport, margin?)
-> { left, top, side }
src/ui/Tooltip.js is the app’s hint for hover and focus. main.js mounts
one tooltip element on document.body, with listeners delegated to the
document. A widget gets a tooltip by marking an element with setTip, and
adds no listeners of its own. setTip writes a data-tip attribute and
clears any native title, so a control never shows two hint boxes. The text
can contain newlines, and the box keeps them.
The button builders route their opts.title through setTip, so most of
the app has tooltips with no change at each call.
- The pointer and the keyboard both show the hint, and the shown element
gets
aria-describedby, so a control reached by Tab reads the same text that a hover shows. - A hover waits one second before it shows the hint, so a pointer that crosses a rail of icon buttons shows nothing. Keyboard focus shows the hint at once, because a Tab press is already a deliberate stop.
- A press, a scroll, or Escape hides the hint. The focus that a click gives to its own control shows no hint, so a pressed button does not bring the box straight back.
- A control removed from the page while it has focus fires no
focusout, so a mutation observer hides the hint when its anchor leaves the document.
The tooltip element is a popover, which puts it in the browser’s top layer.
A modal dialog is in that layer too, so without the popover a hint on a
control inside a dialog draws behind the dialog. tipPlacement is the pure
placement rule: above the anchor and centered on it, moved below when there
is no room above, and kept within the viewport.
src/ui/TileTooltip.js is a separate widget. It follows the cursor over the
map canvas and shows several lines of tile metadata, with no element to
anchor to. It shares the look and nothing else.
Context menus
openContextMenu(items: { label, onSelect, danger? }[], { clientX, clientY }, trigger?)
toggleMenuFrom(trigger, items)
markMenuButton(button)
clampToViewport(x, y, width, height, viewportWidth, viewportHeight, margin?)
src/ui/ContextMenu.js is the right-click counterpart to Modal.js, for
choices that do not need a dialog. It follows the behavior of a native
menu:
- Focus moves into the first item, and the arrow keys cycle through the items.
- Escape or a click outside closes the menu without choosing an item.
- Choosing an item closes the menu before the item’s action runs.
- Only one menu is open at a time, so opening a second menu closes the first.
openContextMenu has no return value, because the items’ own callbacks are
the result.
A menu button, such as the More button of a party row or a world-tree row,
opens its menu with toggleMenuFrom(trigger, items). The menu opens below
the button and takes the button’s accessible name as its own. The button
gets aria-expanded="true" while the menu is open, and a second press on it
closes the menu. The capture-phase press listener ignores a press on the
button, because it would close the menu and the click that follows would
open it again. markMenuButton(button) sets aria-haspopup="menu" and
aria-expanded="false" when the button is built.
An item with danger: true draws in the --danger colour, with a rule
above it when other items come first. The World tree and the party-row More
menu mark Delete this way.
clampToViewport is the pure positioning helper, in its own function so
that a unit test can reach it. It flips the menu away from a viewport edge,
so the menu never slides under one.
A context menu is not a <dialog>, so it stays outside the Modal.js
lifecycle.
Image input
readImageFile(file) -> Promise<string> // a data: URL
fitDimensions(width, height, maxEdge) -> { width, height } // pure
encodeSizes(width, height, maxEdge?) -> { width, height }[] // pure
encodeAttempts(width, height, maxEdge?) -> { width, height, quality }[] // pure
pickFit(candidates, limit) -> string | null // pure
src/ui/imageField.js backs the file field. A picked image becomes part
of the campaign, so the module keeps each image small:
| Limit | Value |
|---|---|
MAX_SOURCE_BYTES |
12,000,000 bytes. A larger file is refused before any decode |
MAX_EDGE |
1280 px on the longest edge |
MAX_ENCODED_CHARS |
250,000 characters in the stored data: URL |
QUALITY_STEPS |
JPEG qualities 0.82, 0.7, and 0.55, tried in order |
readImageFile downscales the image to MAX_EDGE, then walks
encodeSizes: the full edge, then half of it. At each size it draws the
image once. For a PNG source it encodes one PNG from that drawing. It then
encodes a JPEG at each quality step and gives the PNG and the JPEGs to
pickFit, which returns the shortest candidate under MAX_ENCODED_CHARS.
The first result that fits is stored. The error messages are sentences for
the GM, not codes, because they show directly in the dialog.
A saved campaign keeps its image payloads in IndexedDB (see “The image store” in Persistence). The caps still bound what one image adds to memory, to an exported campaign file, and to the localStorage fallback that keeps images when IndexedDB is not available.
The arithmetic (fitDimensions, encodeSizes, encodeAttempts, pickFit)
is pure and unit tested. Check the canvas encode path in a browser.
The CSS layer
The import manifest
style.css contains only @imports of the feature sheets under styles/,
in cascade order, each with a one-line comment. A later sheet can override
an earlier one, so the order is part of the contract:
base.css: design tokens, element base, every shared primitiveshell.css: header, context menu, mode and role switchesbuild.css: Build mode’s world tree, palette, and tile inspectorlayout.css: play-surface columns, map viewport and controls, toastswidgets.css: breadcrumb, dice tray, disclosure, stat bars, fact linesforms.css: the inline authoring form’s frame, rows, captions, and control sizescharacter.css,paperdoll.css,sheet-features.css,inventory-grid.css,session.css,party.css,story.css,quest.css,library.css,spells.css,combat.css,play-shell.css,build-shell.css,full-sheet.css,sheet-summary.css: one sheet for each feature arearesponsive.css: narrow-viewport stacking. Keep this sheet last.
Add a new feature sheet in the feature block, with an @import and a
comment that says what it covers. No .js file imports CSS.
Tokens
Every color, space, radius, type value, shadow, and duration is a custom
property on :root in styles/base.css:
| Group | Tokens |
|---|---|
| Surfaces | --bg, --surface, --surface-raised, --surface-sunken |
| Lines | --border, --border-strong |
| Text | --text, --text-muted |
| Accents | --accent, --accent-hover, --danger, --success, --warning, --mana, and a *-contrast token for each of --accent, --danger, --success, --warning, and --mana |
| Focus and shadow | --focus-ring, --shadow-tint, --shadow-1, --shadow-2, --shadow-3 |
| Over-map controls | --overlay-bg, --overlay-text, --overlay-npc |
| Spacing | --space-1 (0.25rem) through --space-6 (2rem) |
| Type | --font-display, --font-title, --font-sans, --font-mono, --text-display, --text-heading, --text-body, --text-label, --line-body |
| Radius | --radius-sm, --radius, --radius-lg, --radius-pill |
| Motion | --transition-press (40ms), --transition-fast (120ms), --transition-base (250ms) |
The character sheet’s width measures (--sheet-measure,
--sheet-measure-wide, --sheet-measure-side, --sheet-measure-body) live
on :root in styles/character.css (see
Layout and responsiveness).
The token system follows these rules:
- Never write a fallback such as
var(--border, #ccc). A missing token renders as nothing, which shows the typo. A fallback hides it. - Every accent has a
*-contrastpartner, and a filled element always sets its own text color from it. Add a new accent as a pair.
Elevation uses color-mix to fade --shadow-tint to the alpha it needs, so
shadows follow the theme without a second color.
Theming
The app has one set of tokens. Each color is a single
light-dark(light, dark) declaration, resolved by the root color-scheme:
:root { color-scheme: light dark }follows the operating system by default.:root[data-theme='light']and:root[data-theme='dark']pin the theme. The attribute selector outranks the bare:root, so an explicit choice always wins.src/ui/ThemeToggle.jswritesdata-themeon<html>(and removes it for System), and saves the choice undercampaign-builder:theme.src/boot.js, a plain script thatindex.htmlloads at the top of<body>, applies the saved theme again before the first paint, so a reload in the dark theme does not flash light.
--select-chevron is the one themed value that is not a color. It is an
inline SVG data URI, and light-dark() resolves only <color> values, so it
cannot contain a url(). A prefers-color-scheme block and the two
data-theme blocks swap the arrow instead, so the token appears four times.
--overlay-* tokens are dark in both themes, because map controls, toasts,
tooltips, and the onboarding scrim float over map art and not over the page
background.
Shared classes
Class names follow a BEM-like pattern: block__element--modifier. The
classes below are shared across features. Reuse the class, and keep only
layout (margins, grid placement) in the component’s own class.
| Class | Sheet | Role |
|---|---|---|
.btn + --primary, --danger, --success, --icon |
base.css |
Every button, and every link with the button look, built through buttons.js |
.btn-bare |
base.css |
The reset for a control that is a button with no button presentation, built through bareButton |
.field, .field-check |
base.css |
Every input, select, and textarea, and the checkbox with its caption |
.form, __row, __label, __wide, __number |
forms.css |
The inline authoring form and its parts, built through formFields.js |
.card, .card__title |
base.css |
A bordered panel with an uppercase heading |
.seg-switch, __btn, __btn--active |
base.css |
The segmented toggle (mode, theme, role, dice-tray d20) |
.row-select, --current |
base.css |
The selectable full-width list row (world tree, roster) |
.section-label |
base.css |
The in-panel sub-heading: uppercase, tracked, muted, built through sectionLabel |
.modal__section |
base.css |
A section heading inside a long dialog, and inside the rail form that buildSpecForm builds from the same spec: a rule above it, and both columns of a wide dialog |
.empty-state |
base.css |
The “nothing here yet” paragraph. The class sets margin, padding, and italic only. emptyState() adds u-muted for the color and size |
.chip, .chip__remove |
base.css |
A small labeled tag, with or without an x, built through buttons.js |
.badge + --success, --danger, --neutral |
base.css |
A read-only status marker on a list row. A color outside the three shared readings comes from a feature modifier |
.icon |
base.css |
The SVG class that icon() applies |
.tabs, __tab, __panel |
base.css |
A tab strip over a stack of panels |
.modal and its parts |
base.css |
The native <dialog>, built through Modal.js |
.sr-only |
base.css |
Visually hidden, still announced |
.disclosure, __chevron, --open |
widgets.css |
A collapsible header, built through Disclosure.js |
.stat-bar, __track, __fill |
widgets.css |
A filled track, built through buildStatBar, with __fill--mana and __fill--critical, the --compact pill form, and the data-band fill colors |
.fact-line, __label, __value, --row |
widgets.css |
A label with its value, built through factLine |
Utilities
base.css also has a small utility layer for the treatments that every
feature sheet would otherwise restate. A utility describes how something
looks, not what it is. An element keeps its own component class for the
rest of its styling and for any selector that targets it:
el('span', 'npc-panel__location u-muted', label);
| Utility | Declarations |
|---|---|
.u-muted |
font-size: var(--text-label) and color: var(--text-muted), for captions, hints, derived readouts, and row metadata |
.u-row |
display: flex and align-items: center: a horizontal bar with its items centered |
.u-col |
display: flex and flex-direction: column: a vertical stack |
.u-wrap |
flex-wrap: wrap |
.u-g1 through .u-g4 |
A gap on the --space-* scale |
.u-row and .u-col set no gap, because the spacing differs from one
container to the next. Pair them with a gap utility:
el('div', 'encounter-panel__row u-col u-g1', head, chips);
A container that needs a different cross-axis alignment keeps its own
align-items. For example, .character-sheet__head sets stretch, and
.travelog__item sets baseline and does not use .u-row. A gap that is off
the scale or uneven (gap: 0.15rem, gap: var(--space-1) var(--space-3))
also stays a declaration in the component’s rule, beside a bare .u-col.
The utility layer comes before the feature sheets in the cascade, so a
component rule wins where both set the same property.
.tile-inspector__field--inline uses this: it sets its text back to the
full --text color, and keeps u-muted for the size. Every .foo[hidden]
companion rule does the same, and each one outranks the utility’s
display: flex. Without that rule, the hidden attribute has no effect.
Add a new utility only when the pattern already repeats several times and
its values come from the token scale. A component-specific value belongs in
that component’s rule. Weigh the markup changes too: .tabs__panel keeps its
column rule because index.html declares sixteen panels, and u-col u-g4
on each of them costs more than the rule saves. When a utility leaves a
component rule empty, delete the rule and remove the class from the markup.
Keep the class only if another selector still names it, as
.character-sheet__features summary does.
Two details of the .field rule look removable but are needed:
- Single-line controls get an explicit
height. A bare<select>ignoresline-heightfor its box size, and sits about 2.5 px shorter than a neighboring<input>without it. - Selects opt into the customizable-select model (
appearance: base-selectand::picker(select)) as a progressive enhancement. An engine without this support drops the value and uses theappearance: nonerule before it. The closed control is themed everywhere, and only a supporting engine also themes the popup.
Layout and responsiveness
The layout is mostly flex with intrinsic sizing (min(),
flex: 1 1 <rem basis>, repeat(auto-fit, minmax(...))), so most reflow
happens with no media query. Grid is used for tabular content.
The few layout switches are in known places:
- Viewport media queries.
responsive.csshas two:@media (max-width: 68rem)stacks the main columns with the map first (.app-centertakesorder: -1, because the Build rail comes before it in the markup) and shortens the map viewport.@media (max-width: 40rem)shrinks the mini-map and moves it below the map controls.
combat.csshas one more,@media (max-width: 1100px), which stacks the combat screen’s columns. The fit that opens a map never draws a tile smaller thanREADABLE_TILE_PX(32 px,src/map/MapGeometry.js), so a narrow viewport shows a readable part of a large map and not all of it at a quarter size. The Fit button callsfit({ whole: true }), which drops that floor and shows the whole map. - Preference media queries.
base.csshas theprefers-color-schemeblock for the select chevron and aprefers-reduced-motionblock (see Accessibility). - A container query for a component that reflows on its own width.
.character-sheetdeclarescontainer: character-sheet / inline-size, andstyles/character.cssqueries it at50remto lay the sheet’s sections out in two columns. The card’s width depends on whether the sidebar is open and which rails the mode shows, so a viewport breakpoint can only guess it. Inside the query, the head (name and HP bar) and the level, AC, and XP banner sit across from each other on the first row, and a section column sits under each on the second row. The two tracks split the card1.4frto1fr, and the body stops at--sheet-measure-body. Below the query,--sheet-measurecaps the one stacked column. - Card width. The sheet card fills the Sheet tab of the Play sidebar,
so the sidebar width sets it. The sheet measures live on
:rootand not on.character-sheet, so a rule outside the sheet can read them..app-dockcaps its width at 30rem when it stacks below the map, because a roster row and the dice tray rows cannot fill more. - Play shell.
styles/play-shell.cssmakes Play mode a fixed screen of100dvhabove the stacking point ofresponsive.css. A grid on.app-centerdraws the dock left of the map, the dock and each sidebar tab panel scroll on their own. The dock is a flex column. The dice tray takes its natural height at the bottom (flex: none; margin-top: auto), and the Party card takes the rest. The roster inside the Party card (#party-container > .character-roster) scrolls when the tray opens, and the card itself does not, because a scroll box clips the.card__titlethat straddles the card frame. - Build shell.
styles/build-shell.cssmakes Build mode the same kind of fixed screen. The Build rail width isclamp(19rem, 22vw, 30rem), and the swatch grid usesrepeat(auto-fill, minmax(2.875rem, 1fr)), so a wider window adds swatch columns. On the Paint tab the Palette card takes the height that Generate and Tools leave, and.palette__sectionsscrolls inside it down to a 7rem minimum. Below that, the tab panel scrolls. In the stacked layout the page scrolls and the sections have no scroll box. - Body classes for mode and role.
body.mode-play,.mode-build,.mode-library,.mode-combat,.role-gm,.role-player,.role-locked, and.sidebar-collapsedshow or hide whole regions, so a mode switch flips a class and redraws nothing.
A flex child that contains text needs min-width: 0, or long content does
not shrink. The sheets use this rule more than forty times, and a missing
one is the usual cause of a panel that overflows its column.
First paint stability
The browser lays out the page before any module runs, so anything that the wiring changes afterward moves content that the reader already sees. A new panel follows these rules so that the page stays still:
- Decide the body classes up front. A mode or role class hides whole
rails. If
wireSessionControlsapplied them, the page would lay out with every rail showing and then remove some of them. Instead, the<body>element’s class attribute states the starting mode (mode-play) and the default role (role-gm).src/boot.js, a blocking script at the top of<body>, pins the theme and then reads the saved role and the player lock. It is a separate file and not an inline block, so the page’s Content Security Policy can allow scripts from this origin only. Its defaults restate the defaults insrc/main.js, so a change to one default is a change to both. - Reserve the space that a container will fill. An empty container that
grows later pushes everything under it down.
#breadcrumb-containertherefore reserves one crumb’s height from the start, sized from the same tokens that build the crumb and not from a pixel constant. - Reserve the scrollbar.
htmlsetsscrollbar-gutter: stable, so the columns do not all narrow at the moment the panels fill and the page grows past one screen.
Accessibility
The shared layer handles these points, so a new panel does not restate them:
- Focus is one global
:focus-visibleoutline in--focus-ring. The map canvas draws a thicker accent ring, and tabs, context-menu items, and spellbook rows pair focus with their hover treatment..field:focuschanges only the border color, and the global outline still draws the ring. - Names on controls.
iconButtonrequiresaria-label, icons arearia-hidden, andlabelednames each form control through a<label>or a labelled group. - State in ARIA, not only in classes.
wireTabswritesaria-selected, and the CSS styles from it.wireDisclosurewritesaria-expanded. Pill-grid pills usearia-pressed. - Announcements go through live regions. The toast stack has a polite
status region and an assertive alert region, the map has its own
description live region, and file-field errors are
role="alert". The map’s live region is rewritten only when the text changes, because a rewrite announces it again. It gives a count of the points of interest, and the points themselves are in a plain list after the map, so a node with long notes does not read them all on each navigation. - Focus return. Every dialog puts focus back on its opener when it
closes, and the list panels and the roster keep focus across a rebuild
(
focusMemory.js). - Reduced motion. Under
prefers-reduced-motion: reduce,base.csscuts every animation and transition to 0.01 ms, so the reader sees the end state at once. The durations are near zero and not zero, so atransitionendoranimationendlistener still fires.
The app has no handling for these points:
- Touch targets. The app sets no minimum touch size.
.btn--iconis 1.75rem (28 px) square, under the usual 44 px guidance. - Forced colors and contrast preferences. The app has no
forced-colorsorprefers-contrastrules.
Known gaps
These are the places where the shared layer does not yet meet the builder contract, or where the same treatment is written more than once. Reuse the right builder rather than add another copy. When a change passes through one of these places, fix it.
- Builders with no
classNameoption.emptyState,fieldRow,checkboxInput,buildTagsField,openContextMenu, andmountToastsgive the caller no way to add a class. - Mount signatures that differ from the contract.
- The second argument is
callbacksin most panels, butoptionsoroptsinmountCharacterRoster,mountMiniMap,mountTileInspector,mountWorldTree, andmountDiceTray. mountBreadcrumbandmountExitListtake a bare function.mountPalettePaneltakes five positional arguments,mountSpellbookPaneltakes five,mountInventoryPaneltakes eight, andmountCharacterSheettakes ten.mountActiveColumn,mountCombatRibbon,mountCombatLog, andmountRegionPickertake no container and return their element for the caller to place.
- The second argument is
- Section-label treatment restated by hand.
.stat-badge__keyincharacter.cssand.combat-board__headingincombat.csseach restate the uppercase, tracked treatment of.section-label. The builder exists, so these two rules are the remainder.
Testing UI code
The panels and widgets in src/ui/ are the DOM glue half of the project’s
split, so you check them in a browser. Pure helpers that live in src/ui/
have unit tests where they sit:
| Test file | Helpers under test |
|---|---|
tests/imageField.test.js |
fitDimensions, encodeSizes, encodeAttempts, pickFit |
tests/context-menu.test.js |
clampToViewport |
tests/Tooltip.test.js |
tipPlacement |
tests/dialogFocus.test.js |
pickReturnFocus and dialogPartId |
tests/focusMemory.test.js |
captureFocus, restoreFocus, controlSignature (with stub nodes) |
tests/rovingIndex.test.js |
rovingTarget, columnsFromTops |
tests/listPanel.test.js |
repaintNeeded |
tests/formFields.test.js |
captionWrapperKind, uniqueId |
tests/ModalFields.test.js |
assignPill, parseAssignments, formatAssignments |
tests/CharacterRoster.test.js |
rosterDependsOn |
tests/CharacterChecks.test.js |
training, saveRows, skillRows |
tests/InitiativePanel.test.js |
initiativeStatus |
tests/CombatRibbon.test.js |
chipName |
When you add a decision to a panel, export it as a pure function beside the panel and test it the same way.
Preview pages in tests/ mount the real modules against fixtures built by
hand, without the rest of the app:
tests/ui-panels-preview.html: the character sheet, inventory, and encounter panelstests/map-canvas-preview.html: the map canvastests/tile-preview.html: the tile arttests/save-manager-preview.html: the save manager
Update these pages when a mount signature changes. A stale preview page can hide a real error the next time someone opens it.
Testing a change gives the full loop, including how to check both themes and the console.