Repository navigation
architecture pins
Audience: Contributors to the Blacksmith codebase.
This document describes how the Canvas Pins system is built and how its parts interact. It is an architecture reference, not an API reference (see api-pins.md for the public API).
The Canvas Pins system provides configurable, interactive markers on the FoundryVTT canvas. Pins can be placed on a scene (visible on the canvas) or unplaced (data only; typical for notes, quests, etc.). The system is consumed by other modules (e.g. Coffee Pub Squire) and managed by Blacksmith.
Target: FoundryVTT v13+ only. Application V2 is used for the pin configuration window.
Implementation: Pins render as pure DOM (no PIXI graphics) in a fixed overlay. Storage is split: placed pins in scene flags, unplaced pins in a world setting. CRUD, permissions, classification, view state, events, and context menu registration are centralized in PinManager; rendering and coordinate conversion live in the pins renderer.
Read Design rationale first. The system has three deliberately separate concerns — permission (may you see it), classification (what kind of thing is it), and view state (do you want to see it right now). Most pin bugs come from conflating two of them.
-
Where:
scene.flags[MODULE.ID].pins(array of pin objects). -
When: Pins stored in a scene flag array with
xandyare "placed" and appear on the canvas for that scene. Storage is container-based; API reads may enrich returned objects withsceneId. -
Shape: Each pin has
id,x,y,size,style,shape,text,image,config,moduleId,ownership,version, etc. Seepins-schema.jsand the schema section below.
-
Where: World setting
pinsUnplaced(object with apinsarray). Same pin shape as placed pins but withoutsceneId/x/y(or omitted for unplaced). -
When: Created via the API without
sceneId/x/y; moved here when unplaced from a scene. Not rendered on the canvas; used for notes, quests, etc. -
Resolve order: When looking up a pin by id without
sceneId, the system checks the unplaced store first, then any scene.
-
Create (unplaced):
create(data)with nosceneId/x/y→ append topinsUnplaced.pins, fireblacksmith.pins.createdwithplacement: 'unplaced'. -
Create (placed):
create(data)withsceneId,x,y→ append toscene.flags[MODULE.ID].pins, fireblacksmith.pins.createdwithplacement: 'placed'. -
Place:
place(pinId, { sceneId, x, y })→ remove from unplaced, add to scene flags, fireblacksmith.pins.placed. -
Unplace:
unplace(pinId)→ remove from scene flags, add to unplaced, fireblacksmith.pins.unplaced. -
Update:
update(pinId, patch, opts)works for both placed and unplaced; can includesceneId,x,yto place an unplaced pin. -
Delete: Removes from either store; fire
blacksmith.pins.deleted.
Only GMs can write scene flags and the world setting. Non-GM users with edit permission use a GM proxy (socket/requestGM) so the GM client performs the write.
- Responsibility: Single source of truth for pin data, permissions, and event routing.
-
CRUD: Create, update, delete, get, list. Resolves pin location (unplaced vs scene) via
_findPinLocation(pinId). - Place / unplace: Moves pins between unplaced store and scene flags.
-
Permissions:
_canEdit(pin, userId)uses ownership (and optional hookblacksmith.pins.resolveOwnership). Create is gated by world settingpinsAllowPlayerWrites; edit/configure/delete by ownership; GM always full access. -
Events: Registers handlers via
pins.on(eventType, handler, options).VALID_EVENT_TYPES(manager-pins.js:118) has sixteen: nine interaction events (hoverIn,hoverOut,click,doubleClick,rightClick,middleClick,dragStart,dragMove,dragEnd) and seven lifecycle events (created,placed,unplaced,updated,deleted,deletedAll,deletedAllByType). Options can scope bypinId,moduleId,sceneIdand supportAbortSignal; drag events require thedragEventsopt-in. -
Context menu:
registerContextMenuItem(id, item)/unregisterContextMenuItem(id). Modules add custom items; default items (e.g. Delete Pin, Configure Pin) are always shown when applicable. Items filtered bymoduleIdandvisiblefunction. -
Schema: Uses
pins-schema.jsfor defaults, validation, and migration before persisting. - Taxonomy registry: Owns the type/tag vocabulary (see Classification).
- Filter state: Owns per-user view state and named profiles (see View state).
- Responsibility: Visual representation and input handling only. No ownership of pin data.
-
PinDOMElement: Static class that owns the DOM overlay (
#blacksmith-pins-overlay) and one DOM element per pin (div with classblacksmith-pin). No PIXI graphics for pins; styling is CSS (styles/pins.css). Shapes:circle,square,rectangle,none(icon only).rectangleis the image-only free-aspect shape (an image URL keeps its natural aspect with a rounded-corner border; a Font Awesome icon falls back to a forced square). Icons: Font Awesome (class string or HTML) or image URL. -
Coordinate conversion: Scene coordinates → screen pixels via
_sceneToScreen(sceneX, sceneY)usingcanvas.stage.toGlobal()(reuses a PIXI.Point for performance). Positions updated on canvas pan, zoom, and resize (throttled). -
Lifecycle: Initialized on first use. On
canvasReadyandupdateScene, loads pins for the current scene viaPinManager.list({ sceneId })and callsPinRenderer.loadScenePins(sceneId, pins). Clearing the overlay when the scene has no pins. Only placed pins for the active scene are rendered; unplaced pins are never drawn. -
PinRenderer: Same file; orchestrates loading/clearing and delegates DOM creation/update to PinDOMElement. Context menu is custom HTML; "Configure Pin" calls
pinsAPI.configure(pinId, { sceneId }).
-
Responsibility:
BlacksmithLayer(extendsfoundry.canvas.layers.CanvasLayer) is injected intoCONFIG.Canvas.layersatblacksmith.js:824-831. It is a pin lifecycle entry point, not a render surface:_draw()callsPinRenderer.initialize(), andactivate()initializes the renderer and callsPinRenderer.loadScenePins(sceneId, pins). Pin markers themselves render as DOM in the overlay (see the renderer above), not as PIXI on this layer.
- Responsibility: Pin data shape, defaults, validation, and migration.
-
Version:
PIN_SCHEMA_VERSION; each pin has aversionfield. - Defaults: Applied on create/validation (e.g. size, style, shape, ownership). Not necessarily stored to keep payloads small.
-
Validation:
validatePinData(pin, opts)— e.g.allowUnplacedomits x/y requirement. Invalid pins are dropped or repaired on load; scene load never fails due to a bad pin. - Migration: Migration map keyed by version; runs on scene/unplaced read before validation. GMs persist upgraded pins to scene flags or the unplaced world setting when stored data is behind schema (once per scene/unplaced per session). On failure for a pin, drop that pin and log.
- Responsibility: Public interface for other modules. Thin wrapper over PinManager and the windows.
-
Surface: CRUD (
create,update,delete,get,list), placement (place,unplace), events (on), context menu (registerContextMenuItem/unregisterContextMenuItem), UI (configure,openLayers), classification (registerPinType,registerPinTaxonomy,getModuleTaxonomy,getPinTaxonomyChoices), view state (module visibility, filter profiles,getSceneFilterSummary), andreload. -
api-pins.mdis the authoritative contract and is accurate — prefer it over this list, which only exists to show the shape.
- Responsibility: Application V2 window for editing all pin properties.
-
Entry:
pinsAPI.configure(pinId, options)→PinConfigWindow.open(pinId, options). -
Resolve pin:
getData()callsPinManager.get(this.pinId, ...)— unplaced store checked first whensceneIdis omitted. -
Permission:
PinManager._canEdit(pin, userId)ingetData(); window refuses to open without edit permission. - Sections: Permissions, Classification, Pin Design, Text Format, Event Animations, Pin Source.
-
Save: Builds a full patch and calls
pinsAPI.update(this.pinId, patch, { sceneId }). GM-only fields (tags, pin editing, pin visibility, allow duplicates) are applied separately. -
Pin visibility (
config.blacksmithVisibility):'visible'|'hidden'in Permissions (GM only). Hidden = marker not drawn for other players; GM and pin owner always see the pin. -
Pin editing (
config.blacksmithAccess):'gm'|'private'|'public'— who may edit the pin record; maps toownership.default. - Allow Duplicates: Moved from header toggle into the Permissions section body.
-
Update All mode (
_updateAllMode): Toggle in action bar left ("Update All [type] Pins"). When enabled, each section header shows a checkbox; on save, only checked sections are bulk-applied to same-type peer pins after confirmation. Permissions section includes pin editing, pin visibility, and allow-duplicates when checked. -
Use as Default mode (
_defaultMode): "Default for [type]" toggle in the window header. When enabled, each section header shows a separate checkbox; on save, only checked sections are written toclientPinDefaultDesigns(client-scope setting keyedmoduleId|type). Warns if no sections selected. -
Icon label:
formatIconLabel(iconClass)extracts the icon name from a FA class string (e.g.fa-solid fa-skull→skull), skipping style prefix classes (fa-solid,fa-regular, etc.). -
Class:
PinConfigWindow; staticopen(pinId, options). Constructor params:pinId,options.sceneId,options.onSelect,options.moduleId. -
Files:
scripts/window-pin-configuration.js,templates/window-pin-config.hbs,styles/window-pin-config.css.
- Responsibility: The user-facing view-state UI — what this user currently wants to see. It never edits pins.
-
Class:
PinLayersWindow extends BlacksmithWindowBaseV2. -
Surface: Per type/tag show-hide, a hide-all toggle, search, per-scene counts via
PinManager.getSceneFilterSummary(sceneId), and named profiles. -
Hidden pins remain addressable: list/search paths take
includeHiddenByFilterso the window can show and act on pins that the active filter is hiding. A filtered-out pin is not gone — see the three-concerns table below.
-
Responsibility: Surfaces pins inside journal sheets. Uses
JournalDomWatchdog(manager-journal-dom.js) for DOM observation and HookManager only for journal render hooks — seearchitecture-blacksmith.md§9B.1. Do not add a per-feature bodyMutationObserverhere.
Two axes, deliberately (see Design rationale):
-
type— coarse and technical, set by the creating module (e.g.quest-pin,journal-pin). -
tags[]— open-ended and user-facing. Free-form strings and registered vocabulary both allowed.
The taxonomy is the registered vocabulary: per-type labels and suggested tags, plus a set of global tags
that apply everywhere. It is merged from three tiers, later winning, in getPinTaxonomy():
| Tier | Source |
|---|---|
| builtin |
resources/pin-taxonomy.json, loaded by ensureBuiltinTaxonomyLoaded()
|
| override | a GM-supplied JSON at the pinTaxonomyOverrideJson setting path (skipped if it points at the builtin) |
| runtime |
registerPinTaxonomy(moduleId, type, taxonomy) — how consuming modules contribute |
getPinTaxonomy(moduleId, type) returns the merged entry; getPinTaxonomyChoices() unions its tags with the
global tags. Taxonomy is advisory — it drives labels and suggestions, not validation. An unregistered tag
on a pin is legal.
Per-user, never persisted to the pin. This is a preference, not a permission — a filtered-out pin is still there, still visible to everyone else, and still editable.
Backed by client settings on PinManager: pinsHiddenTypeTags, pinsHideAll, pinsFilterProfiles,
pinsActiveFilterProfile.
pinTagRegistry is deliberately not in that list. It is a world-scoped setting (settings.js:3405) — shared tag vocabulary (classification), not per-user view state. Grouping it with the filters above conflates two of the three separable concerns; keep it separate.
-
_isHiddenByFilter(pin)is the single predicate the renderer consults. - Named profiles:
listVisibilityProfiles(),getVisibilityProfile(name),getActiveFilterProfileName(),visibilityStateMatchesProfile(), plus system profiles (isSystemVisibilityProfileName). -
getSceneFilterSummary(sceneId, options)backs the Layers window's counts. -
_matchesListFilters(pin, options)powers filtered list queries.
-
Scene load / change:
canvasReadyorupdateScene→ renderer schedules load →PinManager.list({ sceneId: canvas.scene.id })→ only placed pins for that scene. -
Pin list: PinManager returns array from
scene.flags[MODULE.ID].pins. -
Pre-filter — before any DOM is created (
pins-renderer.js):Both gates run up front, not per-frame and not after render.const visiblePins = pins.filter((pin) => this._canUserSeePin(pin, userId, PinManager) && !PinManager._isHiddenByFilter(pin));
_canUserSeePinis the permission gate (ownership +blacksmithVisibility);_isHiddenByFilteris the view-state gate. A pin failing either never becomes a DOM node. This is the system's main performance lever — see Design rationale. - DOM: For each surviving pin, PinDOMElement creates or updates a div (position, size, shape, icon, text from pin data). Coordinates converted from scene to screen; overlay is fixed, so pins are positioned in screen space.
-
Pan / zoom / resize: Throttled update runs
_sceneToScreenfor each pin and updates div position/size. Pins can be hidden during pan/zoom for performance, then shown again after.:rootsizing variables are read throughPinDOMElement._rootVar(name), which caches them for one pass. Never callgetComputedStyleinside this per-pin loop: each read after the previous pin's style writes forces a style recalc per pin per frame.
Changing filter state does not reload the scene. PinManager filter mutations call PinRenderer.applyVisibilityFilters() (pins-renderer.js:2273), which reconciles the existing DOM against the active filter — creating, removing, or showing/hiding pin nodes as needed — without re-running the loadScenePins path.
Pins render into a DOM overlay that is a sibling of the canvas app element, not as PIXI objects. A canvas layer does exist — BlacksmithLayer (see Components) — but it serves only as a pin lifecycle entry point; no pin graphics live on it.
_calculatePinPosition picks a tier per pin per pass from its natural screen size, the shorter side
times the zoom, via _zoomDetailFor, and the renderer writes it to data-zoom-detail only when it changes.
styles/pins.css does the hiding; a tier change creates or removes no DOM.
| Tier | Natural size | Drawn at | Shows |
|---|---|---|---|
full |
at or above --blacksmith-pin-min-screen-size (24px) |
natural size | everything |
compact |
below the minimum, at or above --blacksmith-pin-dot-threshold (12px) |
the minimum, aspect kept | shape and icon; label on hover only; no pin-editing glyph |
dot |
below the dot threshold |
--blacksmith-pin-dot-size (10px), a disc in --pin-fill-color (set for every shape, so a none-shape pin still has one), with a thin light ring in place of the drop shadow, which is wider than a dot and merges neighbours into one smudge |
nothing else; linear label on hover, arc label never; hit area kept at the minimum by ::after
|
- Below
full,_calculatePinPositionreturns an effective scale,minimum / shorter side, rather than the zoom. Stroke, glyphs and text all derive from it, so a compact pin is drawn exactly as a minimum-size pin would be. Position never changes: every tier centers on the same scene point. - The CSS rules are
!importantbecause text display, border and background are written inline. On hover the inline display decides, soneverand a non-GM'sgmlabel stay hidden at every tier. - A minimum of
0pxturns zoom detail off. The thresholds are:rootvariables rather than settings: a per-user setting would need the world-scoped Pins heading madeuser. -
Arc labels rebuild only when their layout inputs change (text, position, pin size, text size, stroke,
textMaxWidth), keyed intextElement.dataset.arcKey. The characters sit relative to the pin, so a pan needs no rebuild._createCurvedTextmeasures every character withoffsetWidth, so rebuilding it per frame was the costliest thing on the pan path._updatePinTextclears the key whenever it rebuilds.
World setting pinsClusterWhenZoomedOut, on by default. _applyClusters runs at the end of every
updateAllPositions pass, reading each pin's screen center and tier from PinDOMElement._layout, which
updatePosition fills.
-
Only
compactanddotpins cluster. A pin at full size is always drawn as itself. - Groups are connected components of pins whose screen centers lie within the minimum pin size of each other, found by union-find over a grid of minimum-size cells. Screen distance does not change on a pan, so groups change only on zoom.
- Excluded: pins hidden by filter (so they never inflate a count) and the selected pin (so the pin being acted on, or dragged, stays put).
- Members get
data-clustered="true", which CSS hides. The badge,.blacksmith-pin-cluster, sits at the members' screen centroid, minimum-size, above every pin's stack order. Badges are kept inPinDOMElement._clusters, keyed by the sorted member ids, so an unchanged group reuses its element. -
Tooltip lists member labels, obeying each pin's text display: a
neverlabel, or agmlabel for a player, is left out, so a cluster cannot reveal what the pin hides. With no labels it reads "N pins". -
Click pans to the members' centroid and zooms in at least 1.5x, far enough that the smallest member is
full size, capped at
CONFIG.Canvas.maxZoom. -
Re-clustering is scheduled (
_scheduleUpdate) when a pin is created, updated, removed or filtered, on deselect, and when the setting changes (PinRenderer.refreshLayout)._scheduleUpdatedoes nothing until the renderer is initialized.
- DOM: PinDOMElement attaches listeners to each pin div (mouse down, hover, drag).
-
Delegation: On interaction, the renderer resolves
pinIdandpinDataand callsPinManager._invokeHandlers(eventType, pin, sceneId, …)with structured payload (e.g. modifiers, originalEvent). - Handlers: PinManager looks up registered handlers for that event type (and optional pinId/moduleId/sceneId) and invokes each. Errors are isolated so one failing handler does not break others. AbortSignal and drag opt-in are respected.
| Input | Result | Offered to modules |
|---|---|---|
| Left press | Selects the pin (on press, so dragging an unselected pin selects it) | No |
| Second left click, same pin, within 300 ms | doubleClick |
Yes |
| Right press | Context menu | rightClick |
| Middle press | middleClick |
There is no click event. A single left-click is the pins tool's and cannot be mapped by a module; VALID_EVENT_TYPES omits it so registerHandler('click') throws rather than silently never firing. Double-click is detected as two clicks inside DOUBLE_CLICK_MS on the same pin (PinDOMElement._registerClick), not with a timer, so a single click acts immediately.
State lives on PinDOMElement (_selectedPinId, _selectedCanEdit, _lastDeleted); the selected node carries data-selected="true", styled in pins.css. One pin at a time. Nothing outside the renderer reads it, and it is not part of the public API.
-
Select:
PinDOMElement.select(pinId, { canDelete })on left press.canEditis the same_canEdittest that gates the menu's Configure Pin and Delete Pin, resolved at press time because the key handler must answer synchronously. -
Deselect:
Escape; apointerdownanywhere outside.blacksmith-pinand the pin context menu;removePin;clear(scene change); overlay hidden. -
Keys: one capture-phase
keydownlistener ondocument, installed ininitializeand removed incleanup. Capture matters: Foundry's keyboard manager listens onwindowin the bubble phase, so a handled key stopped here never reaches core's Delete keybinding, which would otherwise also delete a controlled token. It ignores modified keys, auto-repeat, and any target insideinput, textarea, select, prose-mirror, [contenteditable]. -
Delete / Backspace call
PinDOMElement._deletePin, which the context menu's Delete Pin also calls -- one path, so the delete animation, permission check, GM relay anddeletedevent cannot drift apart. -
Enter calls
_configurePin, which the menu's Configure Pin also calls. A focused button or link keeps its own Enter. -
Ctrl/Cmd+Z restores the last pin a GM deleted.
_deletePinsnapshots the pin (PinManager.get) before deleting and keeps one entry in_lastDeleted;_undoDeletecallsPinManager.createwith the original id, position and settings, then selects it. The entry expires afterUNDO_DELETE_MS(60 s) and is dropped if the scene changed. Ctrl+Z is claimed only while an entry is live, so Foundry's own undo is untouched otherwise. GM only, becausecreateis a GM write. Restoring firescreated, not an "undeleted" event, so a satellite that tears down state ondeletedsees a fresh pin. -
Ctrl/Cmd+C copies the selected pin into
PinDOMElement._clipboard(data only, per session, lost on reload). A pin is copyable only if its type's taxonomy entry sayscopyable: true(default false, so undeclared means refused). The owner of a type is the only one who knows whether a second pin is safe: a note stores its pin's id, and a gather spot is reconciled against a target count. Blacksmith declares it forjournal-pinand nothing else. Only an explicit boolean takes part in the taxonomy merge, so a user override that omits it does not undo the module's declaration. Anything refused warns "This pin cannot be copied." and empties the clipboard. A Ctrl+C with no pin selected also empties it, so the last copy wins and a stale pin cannot hijack Foundry's own paste of a token. -
Ctrl/Cmd+V creates a new pin (fresh id, everything else copied) under the mouse and selects it. Claimed only while the clipboard is filled and the pointer was last over the board or a pin. The position comes from a passive
pointermovelistener ondocumentthat recordsclientX/clientY, converted by_clientToScene;canvas.mousePositionis not used because the canvas gets no pointer events while the mouse is over a pin's DOM node. A user who may not create pins gets a warning.
A pin's moduleId is part of how everything finds it: Librarian lists pins by its own id, handlers filter on it, and the taxonomy and type labels are keyed by moduleId|type. A feature that moves between modules therefore strands the pins made before the move, which keep the old module's id and read as untracked and unlabelled. PinManager.adoptPins is the one place that rewrites it: it maps every scene's pin flag and the unplaced store, changing moduleId on the matching pins and nothing else, and fires blacksmith.pins.adopted. The module that now owns the feature calls it, with the types it is taking. Blacksmith deliberately keeps no vocabulary for a module that no longer makes pins: until the new owner has adopted them, those pins read as untracked, and after it they are the new owner's like any other.
A type declares target in its taxonomy entry (see api-pins.md). PinManager.resolvePinTarget(pin) returns { declared, uuid, doc, broken }: the first listed config key holding a string is taken as a UUID and resolved with fromUuid, which tests existence and not permission. Three places use it:
-
The glyph.
PinRenderer._applyTargetStatus, called at the end of_applyVisibilityForPin, so every create and update is covered, sets or clears a broken-link glyph (PinDOMElement._setBrokenIndicator) and a tooltip on the pin. Only_canEditusers get it, which is the GM and the owners. -
Re-checks.
PinDOMElement.initializehooksdelete<Document>for every world document type plusJournalEntryPage, except chat messages, andPinRenderer.refreshTargetStatusre-checks every drawn pin 150 ms after the last one. Creating a document does not trigger a re-check, so a restored document heals its pin on the next scene load. -
Double-click.
_registerClickresolves the target before deliveringdoubleClick. A broken pin gets a warning and the event is withheld, which is part of the contract a satellite accepts by declaringtarget.
The taxonomy is loaded asynchronously, so resolvePinTarget awaits ensureBuiltinTaxonomyLoaded first; a pin drawn before the JSON arrives would otherwise read as having no target and never be re-checked.
Blacksmith declares target for journal-pin (journalPageUuid, journalUuid) and for note (noteUuid). Declaring note also gives Blacksmith's note pins a type label, which they lacked: the Configure Pin window header and its Update All toggle read blank for a type with no label. The window now falls back to the type key, title-cased, for any type that registers none.
A type opts in with relinkable: true beside target. For the same reason as copyable, only the owner of a type knows what else is bound to the document a pin points at, so Blacksmith writes the one config key that held the dead UUID (resolvePinTarget returns it as key) and announces relinked, and the owner repairs the rest.
-
Search.
PinManager.findRelinkCandidatestakes the kind of document from the dead UUID (foundry.utils.parseUuid(uuid).type). The pin keeps two clues, that kind and its owntext, which is normally the document's name. Compendiums are searched first, then the world, because a world is rebuilt often and its documents get new ids while the compendiums they came from stay put. The compendium half goes throughapi.compendiums.search(Blacksmith as consumer zero), so the Compendium Mapping and its priority order apply,allSourcesadds the unmapped packs, andincludeWorldstays off: the world half is ours, since it also has to find a page, which the Compendiums API does not index. A compendium holds journals and not their pages, so for a page the search opens up to six journals named alike and offers their pages (those named alike, or a journal's only page). The world half scores 100 for the same id, 80 for an exact name and 40 for a contained name. -
Where to look. The caller passes
sources('compendiums','world','both'), defaulting to the world settingspinsRelinkSource,pinsRelinkAskandpinsRelinkAllCompendiumsread bygetRelinkDefaults. The dialog asks first (api.dialog.choose, the setting preselected) unless asking is off. A type withrelinkScope: 'world'skips the question, is searched in the world only, andrelinkPinrefuses a document that has apack: a satellite whose records are world documents has nothing to find in a compendium. Compendium results are not walked beyond the Compendiums API's own limits, and its first unscoped call indexes every pack, which is why that setting says so. -
Write.
PinManager.relinkPinrequiresrelinkable, a key to rewrite, a document that resolves, and the same document type as before, then callsupdatewith the new config and firesblacksmith.pins.relinkedand the registeredrelinkedhandlers. The old pin has no live document to compare against, so "same kind" is judged from the dead UUID. -
Dialog.
scripts/utility-pin-relink.jsisapi.dialog.promptwith anentityListof candidates and a box that takes a dropped document or a pasted UUID. Nothing is preselected, so Enter cannot relink to a guess. A typed or dropped UUID wins over the list. A wrong kind or a missing document throws fromonSubmit, which reopens the dialog with the message. -
Entry points. The Relink button in the Linked to row of Configure Pin, which swaps the row in place rather than re-rendering, since a re-render would discard unsaved edits; and Relink Pin in the right-click menu. Both are GM only and appear only for a broken pin of a
relinkabletype. -
Repairing many.
PinRelink.openBulkis the same machinery over a scene, or over a selection:PinManager.listBrokenPins(sceneId, { pinIds })finds the broken pins of relinkable types and counts the ones that cannot be relinked; one question asks where to look (skipped when every pin is world only);findRelinkCandidatesruns once per pin; and one dialog lists every pin with a dropdown of its matches, Leave broken by default. A match is preselected only when exactly one candidate has the same id or the same name, and nothing is written until Apply, so a preselection is a proposal. Apply callsrelinkPinper pin withinteractive: false, so no handler asks a question per pin, andrenameToMatchfrom one checkbox stands in for the journal pin's rename question. Failures are collected and reported together, not thrown, since a throw reopens the dialog. The entry point is Repair Links in the action bar of the Manage Pin Links tab (GM). Scene only, because Manage Pins is per scene. -
Journal pins. Blacksmith is the first consumer.
_onJournalPinRelinkedinui-journal-pins.jsmoves what a journal pin keeps about its document:journalIdandpageId(orjournalIdandjournalUuidfor a whole-journal pin) in its config, and thepinIdandsceneIdflags on the new page or journal that the toolbar reads. A page that allows several pins carries nopinIdflag, as at placement, and a page in a compendium is locked, so a pin pointed at one carries no flags. Note pins are notrelinkable: a note keeps an anchor that names its pin, and Notes would have to move it.
The third tab of the Manage Pins window lists every pin on the scene with what it points at. PinLayersWindow._loadLinkRows resolves each pin through PinManager.resolvePinTarget, in getData, so only this tab pays for the async lookups and the other two stay instant. Four statuses: Linked (the document resolves), Broken (it does not), No link (a type that records a target, on a pin that holds none) and Not tracked (a type that declares no target, so nothing can be said). Rows sort broken first, then linked, then the two quiet statuses, then by name; unlinked and untracked rows are dimmed, so the tab doubles as a map of which pin types take part. A linked row carries a data-link anchor, which Foundry opens in the client, with the document's kind and its compendium when it has one. A broken, relinkable row has a Relink button that runs the single-pin dialog; Repair Links in this tab's action bar runs the batch. The filter box is shared with the Manage Pin Tags tab and also matches the target's name; Broken only is local to this tab.
The Linked to row is an anchor carrying data-link and data-uuid, which is Foundry's own content-link contract: core's body-level click handler resolves the UUID and opens the document in this client (a journal page opens its journal at that page). The window has no click handler of its own for it, and the anchor has no href, so there is nothing for the browser to navigate to.
Five tabs, built from the shared .blacksmith-tabs markup and the shared selectTab action (data-action="selectTab" data-value, role="tab", the instance's activeTab), as window-pin-layers.js and window-json-import.js do. The one difference is deliberate: _selectTab shows and hides panes in place and does not re-render, because this window is a form and a re-render would discard what was typed on the other tabs. Every pane stays in the DOM, data-active-tab on the root decides which shows, and Save reads them all. The tabs are General (Linked to, Permissions), Tags, Image (Pin Source), Appearance (Pin Design, Text Format) and Animations. A player who owns a pin has no General or Tags tab, and _resolveActiveTab falls back to the first tab they can see.
To add a selected-state command, add a key branch in _onDocumentKeyDown. Claim the key (preventDefault and stopPropagation) only when you will act on it.
-
Foundry ownership (
ownership.default/users): Gates_canView(level > NONE) and_canEdit(level ≥ OWNER for non-GMs). GM sees all. -
Pin visibility (
config.blacksmithVisibility):'visible'or'hidden'— whether others see the marker on the map (hidden = not drawn). GM-only in UI. -
Pin editing (
config.blacksmithAccess): Who may edit the pin shell; independent of what the module does on click. - Module behavior: Click/double-click and document edit rights are not implied by pin editing or pin visibility.
-
Create: Allowed only if
pinsAllowPlayerWritesis true or user is GM. -
Edit / Configure / Delete: Any user with OWNER (or higher) on the pin can edit, configure, or delete that pin, regardless of
pinsAllowPlayerWrites. GM can always do everything. -
Write path: Scene flags and world setting
pinsUnplacedare written only by the GM client; non-GM editors go through requestGM/socket so the GM performs the write.
Applied during create/validation when not provided:
-
size:
{ w: 32, h: 32 } -
style:
{ fill: "#000000", stroke: "#ffffff", strokeWidth: 2, alpha: 1, iconColor: "#ffffff" } -
shape:
'circle' -
version:
PIN_SCHEMA_VERSION -
ownership:
{ default: 0 } -
tags:
[] - text: undefined
- image: undefined (or Font Awesome class string / image URL)
-
config:
{}
Image supports Font Awesome class strings and image URLs (no HTML in stored value). Full schema and validation rules are in pins-schema.js.
PIN_SCHEMA_VERSION is currently 7. Migrations are keyed by version and run on read, before validation.
Read them in pins-schema.js rather than trusting this list to stay current — but the shape of the history
matters, because two of them are why the model looks the way it does:
| → | What it did |
|---|---|
| v2 | added type, defaulting to 'default'
|
| v3 | added group and tags
|
| v4 |
removed group, promoting non-empty values into tags
|
| v5 | renamed type journal-page → journal-pin
|
| v6 | decoupled legacy access presets from visibility |
| v7 |
dropped owner visibility, mapping it to visible
|
v3 introducing group and v4 deleting it is the whole argument for the two-axis model. v7 is why no
consumer writes 'owner' anymore.
| Concern | Location / mechanism |
|---|---|
| Placed pin storage |
scene.flags[MODULE.ID].pins (array) |
| Unplaced pin storage | World setting pinsUnplaced (object with pins array) |
| Rendering | DOM overlay #blacksmith-pins-overlay; PinDOMElement per pin; styles/pins.css
|
| Coordinate conversion |
PinDOMElement._sceneToScreen() using canvas.stage.toGlobal (PIXI.Point reused) |
| CRUD & permissions | PinManager (manager-pins.js) |
| Schema & migration | pins-schema.js |
| Classification vocabulary |
resources/pin-taxonomy.json + pinTaxonomyOverrideJson setting + registerPinTaxonomy(); tag registry in world setting pinTagRegistry
|
| View state | Client settings pinsHiddenTypeTags, pinsHideAll, pinsFilterProfiles, pinsActiveFilterProfile
|
| Render pre-filter |
pins-renderer.js — _canUserSeePin() && !PinManager._isHiddenByFilter() before DOM creation |
| Public API |
api-pins.js → game.modules.get('coffee-pub-blacksmith')?.api?.pins
|
| Config UI |
window-pin-configuration.js (PinConfigWindow); opened via pinsAPI.configure(); context menu "Configure Pin" in pins-renderer.js
|
| Layers / filter UI |
window-pin-layers.js (PinLayersWindow); counts via getSceneFilterSummary()
|
| Journal pins |
ui-journal-pins.js — via JournalDomWatchdog, HookManager-only |
| Hooks |
blacksmith.js: canvasReady / updateScene trigger pin load; dropCanvasData for dropping pins onto canvas |
- Scene flags for placed pins: reliable and persistent.
- State-driven appearance: visuals update from pin data.
- Drop canvas data hook: clean integration with Foundry drag-and-drop.
The design spine. These are independent axes and conflating them is the main way pin work goes wrong:
| Concern | Question it answers | Mechanism |
|---|---|---|
| Permission | May this user see or edit the pin? | Hard gate — blacksmithAccess / blacksmithVisibility, ownership |
| Classification | What kind of thing is this pin? |
type (coarse, technical) + tags[] (user-facing, open-ended) |
| View state | Does this user want to see it right now? | Per-user filters + named profiles; never persists to the pin |
Permission is a gate; view state is a preference. A filtered-out pin is not a hidden pin. Never express a permission decision as a filter, or vice versa.
-
No PIXI for pin graphics: Pins are DOM elements in a fixed overlay; the
BlacksmithLayercanvas layer is only a lifecycle entry point, and only coordinate conversion uses the canvas/PIXI stack. - Single init: Pin overlay and hooks initialized once (e.g. on first use); no duplicate creation across hooks.
- Unplaced vs placed: Explicit two-store model (world setting vs scene flags) so most pins can stay unplaced and only some are placed.
- Event cleanup: Handlers can be unregistered via AbortSignal or explicit API.
- Validation and migration: Schema version and migration map; invalid pins dropped without failing scene load.
- Context menu: Registration API so modules add items without editing core renderer code.
-
type+tags[], nottype+group+tags[]. The model originally had a thirdgroupfield. Schema v4 removed it and promoted its values intotags(pins-schema.js— the v3→v4 migration deletesgroup). Two axes were enough: one coarse and technical (type), one open-ended and user-facing (tags). A middle tier was a third thing to name, keep consistent, and explain. Don't reintroduce it. - Pre-filter over viewport culling. The renderer excludes pins by permission and active filter before DOM creation, rather than culling off-screen pins. The suspected pressure points are DOM node count, per-pin coordinate work on pan/zoom, icon rendering, and event overhead — not raw memory. Pre-filtering attacks node count directly and is far simpler than a viewport system. Culling was deliberately deferred, and should not be built without a measurement first.
- Zoom detail first, clustering on top (2026-10-10). Pins scale with zoom, so zoomed out they never collide; they shrink until they cannot be read or hit. Zoom detail fixes that and keeps each pin on its exact spot, and it is clustering's prerequisite, since without a minimum size nothing collides. Clustering was first deferred, because a badge replaces where with how many, then built the same day once a real map showed about ten pins overlapping even at full size. It is a readability feature, not a performance one: every position must still be computed to group them. Clusters mix types. Same-type clusters were considered; on a crowded spot they leave several badges overlapping, which is the glob clustering exists to remove. No per-type opt-out exists. Add a taxonomy key only when a type genuinely needs true scene size at every zoom, such as a measured area marker.
- Ai assistance
- Applicationv2 traps
- CSS traps
- dnd5e conditions
- Documentation standard
- Token actor identity
- Campaign
- Canvas
- Chatcards
- Compendiums
- Contextmenu
- Core
- Create journal entry
- Dialog
- Active Effects
- Entity list
- Equiplocations
- Geography
- GM request
- Gmnotes
- Health
- Hookmanager
- Importer
- Inventory
- Menubar
- Notes
- Party
- Pins
- Quantity split
- Requestroll
- Rolls
- Scene config
- Sockets
- Stats
- Tags
- Toast
- Tokens
- Toolbar
- Window
- Worldclock