renderCoordLabelsLayer read each hex's offsetWidth/offsetLeft/offsetTop and
created the label DOM inside the SAME loop. Every offset read after a DOM write
forces a synchronous layout flush, so the loop was O(n²): on the chult map
(3843 hexes) opening stalled the main thread ~21s (regression from commit
10475fd, the coord-label layer).
Split into a read phase (collect all hex geometry into an array) then a write
phase (create all labels) — matches the read-then-write pattern already used by
renderPathOverlay/renderFactionOverlay. ~21s → ~0.1s (dev/coord-label-thrash-
bench measures ~198× batched vs interleaved).
Bakes in a regression guard: CLAUDE.md convention forbidding interleaved
layout-read / DOM-write in per-hex loops, plus the committed benchmark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Commands
# Development (watch mode with inline sourcemaps) — use with Hot Reload (see below)
npm run dev
# Production build (type-checks then bundles)
npm run build
# Run tests (vitest)
npm test
# Bump version (updates manifest.json and versions.json, stages both)
npm run version
Slash commands (invoke inside Claude Code):
/dev— starts esbuild in watch mode (long-running; pairs with Hot Reload)/rebuild— one-off production build + test run; reports errors if either fails
Always run /rebuild after making code changes. The TypeScript type-check and test suite are the only automated verification — catch errors before the user does.
Dev loop
Each coding session:
- Run
npm run devin a terminal (or/devfrom Claude Code) — esbuild watches for changes and rebuildsmain.json every save - After a rebuild, reload the plugin in Obsidian:
powershell.exe -Command "obsidian plugin:reload id=duckmage-plugin" - Use
/rebuildfor a final production build before committing (runs the TypeScript type-check and tests too)
Architecture
The plugin source is split across main.ts (entry point re-export) and modules under src/. esbuild bundles everything into main.js, which Obsidian loads directly.
See ARCHITECTURE.md for a full overview of the system.
Source layout
main.ts ← thin re-export: export { default } from "./src/DuckmagePlugin"
src/
DuckmagePlugin.ts ← Plugin class (entry point, default export)
HexMapView.ts ← ItemView — hex grid, all drawing tools, inline modals
HexEditorModal.ts ← Modal — right-click per-hex editor (terrain, links, notes, icon override)
HexTableView.ts ← ItemView — spreadsheet view of all hex notes with filters/sort
TerrainPickerModal.ts ← Modal — full terrain palette picker for the terrain paint tool
TerrainEntryEditorModal.ts ← Modal — edit a single terrain palette entry (name, color, icon)
IconPickerModal.ts ← Modal — icon picker for the icon paint tool
RegionModal.ts ← Modal — switch active region, create/rename/delete regions
FileLinkSuggestModal.ts ← SuggestModal — file picker scoped to worldFolder
RandomTableView.ts ← ItemView — random table + workflow browser (tabbed: Tables / Workflows)
RandomTableModal.ts ← Modal — inline roll modal used from HexEditorModal
RandomTableEditorModal.ts ← Modal — edit entries of a random table file
WorkflowEditorModal.ts ← Modal — edit workflow definition (steps, template, results folder)
WorkflowWizardModal.ts ← Modal — execute a workflow (roll steps, fill template, save as note)
randomTable.ts ← Pure logic: parse, roll, weight, die-range helpers
workflow.ts ← Pure logic: parse/serialize workflows, generate templates, placeholder helpers
DuckmageSettingTab.ts ← PluginSettingTab — settings UI
types.ts ← Interfaces & type constants (TerrainColor, DuckmagePluginSettings, LINK_SECTIONS, TEXT_SECTIONS)
constants.ts ← Runtime constants (VIEW_TYPE_*, DEFAULT_TERRAIN_PALETTE, DEFAULT_SETTINGS)
defaultHexTemplate.md ← Built-in hex note template (imported as text via esbuild loader)
frontmatter.ts ← YAML frontmatter helpers (terrain + icon override read/write)
sections.ts ← Markdown section helpers (addLinkToSection, getLinksInSection, getAllSectionData, …)
utils.ts ← Shared utilities (normalizeFolder, getIconUrl, makeTableTemplate)
md.d.ts ← TypeScript declaration for "*.md" text imports
The .md loader is configured in esbuild.config.mjs (loader: { '.md': 'text' }), allowing defaultHexTemplate.md to be imported as a plain string.
Plugin purpose
Renders an interactive hex-grid map for tabletop RPG world-building inside Obsidian. Each hex cell corresponds to a Markdown note on disk. The map supports terrain painting, icon painting, road/river chain drawing, link-to-hex tools (random tables, factions), panning, and zooming. A spreadsheet view summarises all hex notes with filtering. A random tables view lets users browse, roll, and edit weighted random tables, and execute multi-step workflows that chain table rolls into a filled template note.
Key classes
-
DuckmagePlugin(src/DuckmagePlugin.ts) — Main plugin entry point. Registers views (HexMapView,HexTableView,RandomTableView), ribbon icons, commands, and the settings tab. Key public API:hexPath(x, y)— vault-relative path for a hex notecreateHexNote(x, y)— creates hex note from templateloadAvailableIcons()— merges pluginicons/with user custom icons folder intoavailableIcons: string[]refreshHexMap()— re-renders all openHexMapViewinstancesensureTerrainTables()— creates missing terrain table files undertablesFolder/terrain/ensureAllRollerLinks()— adds roller-link preamble to all table filesbackfillTerrainLinks()— links each hex note's terrain table into its Encounters Table sectionbuildRollerLink(path)— generates the[Roll](<obsidian://…>)URI for a table file
-
HexMapView(src/HexMapView.ts, extendsItemView) — Renders the hex grid and handles all user interaction.- DOM structure:
contentEl(.duckmage-hex-map-container) →.duckmage-hex-map-clip(panning viewport,overflow: hidden) +.duckmage-hex-map-controls(overlay for buttons,pointer-events: none). This keeps toolbar/expand buttons unclipped by the viewport transform. renderGrid(terrainOverrides?, iconOverrides?)— full DOM re-render. Optional override Maps allow passing values not yet in the metadata cache.- Drawing tools:
drawingModeunion"road" | "river" | "terrain" | "icon" | "tableLink" | "factionLink" | null. Toolbar buttons toggle mode; each mode has ahandle*Button()opener,exit*Mode()closer, andonHex*Click()handler. - Write queues:
scheduleTerrainWrite/scheduleIconWritecoalesce rapid repaints — only the latest value per hex is written, preventing stale overwrites. - Link tools (
tableLink,factionLink): pick a file via folder-tree modal, then click hexes to add a wiki-link to theEncounters TableorFactionssection. Visual feedback: badge span + CSS ripple blip animation (duckmage-hex-blip). - Left-click: opens/creates hex note (normal), paints terrain/icon, extends road/river chain, or adds a link.
- Right-click: opens
HexEditorModal(normal), deletes road/river node, or exits current tool mode. - Expand buttons (+) grow the grid in four cardinal directions, adjusting
gridOffsetandgridSizein settings. centerOnHex(x, y)(public) — pans the viewport to centre on a given hex coordinate.activeRegionName(public) — the currently displayed region name.
- DOM structure:
-
HexEditorModal(src/HexEditorModal.ts, extendsModal) — The right-click per-hex editor:- Terrain picker (2-row scrollable grid) with icon override and "Clear terrain".
- Dropdown link sections for Towns, Dungeons, Features, Quests, Factions, Encounters Table — each backed by its own configured folder, rendered via
renderDropdownSection. UsesLinkPickerModalinternally (file list + create-new). - Free-text note sections: Description, Landmark, Hidden, Secret, Weather, Hooks & Rumors.
- Fetches all section data in a single read before touching the DOM (
getAllSectionData).
-
HexTableView(src/HexTableView.ts, extendsItemView) — Spreadsheet of all hex notes:- Columns: Hex (coords + jump button), Terrain, Description, Landmark, Towns, Dungeons, Features, Quests, Factions, Enc. Table, Hidden, Secret, Weather, Hooks & Rumors.
- Enc. Table cell shows the basename of the linked table file; clicking opens
RandomTableViewat that file. - Toolbar filters: X/Y range inputs, terrain multi-select (left-click include / right-click exclude), Has Town/Dungeon/Feature/Quest/Faction checkboxes, region selector, X→Y / Y→X sort priority, Asc/Desc direction.
- Live updates on vault
modifyevents (300 ms debounce per file). - Jump button (◎) calls
HexMapView.centerOnHex(x, y)on the open map view.
-
RandomTableView(src/RandomTableView.ts, extendsItemView) — Tabbed browser with two modes:- Tables mode: folder tree + search + new-table creator. Detail pane shows entries with die-range and % odds, die-size selector, Roll button, copy result, roll history. Edit opens
RandomTableEditorModal. Bottom of detail pane shows "Workflows using this table" links (each opens that workflow in Workflows mode) and a "+ New workflow with this table" link. - Workflows mode: folder tree of workflow files. Detail pane shows each step as a clickable link (opens that table in Tables mode) with roll count. Edit opens
WorkflowEditorModal. "Roll workflow" button opensWorkflowWizardModal. openTable(filePath)(public) — switches to Tables mode, expands ancestor folders so the target is visible, and loads the table detail; called fromHexTableView(Enc. Table cell),HexEditorModal, workflow step links, and the protocol handler.
- Tables mode: folder tree + search + new-table creator. Detail pane shows entries with die-range and % odds, die-size selector, Roll button, copy result, roll history. Edit opens
-
RandomTableModal(src/RandomTableModal.ts, extendsModal) — Lightweight inline roll modal opened fromHexEditorModal(🎲 button per link section or 📖 for terrain description). Skips the picker ifinitialFilePathis supplied. -
RandomTableEditorModal(src/RandomTableEditorModal.ts, extendsModal) — Edit table entries (result text + weight), linked folder, description, filter flags. Auto-saves on close. Accepts optionalinitialContentto avoid a redundant vault read when the caller already has the file content. -
WorkflowEditorModal(src/WorkflowEditorModal.ts, extendsModal) — Edit a workflow definition:- Rename workflow, set results folder, manage steps (table picker + roll count + label) with drag-to-reorder.
- Template textarea with live placeholder validation — shows missing
$placeholdernames. - Template auto-syncs: new step appends
## label\n$labelblock; label change updates the heading AND all$var/$var_Nplaceholder references; roll-count change adds or removes_Nsuffixed placeholders (reducing to 1 collapses back to bare$var). - Auto-saves workflow file and template file on close. Modal is draggable by its title bar.
-
WorkflowWizardModal(src/WorkflowWizardModal.ts, extendsModal) — Execute a workflow:- Shows each step with a manual-pick dropdown and a Roll button. Fixed-width controls to prevent layout shift on roll.
- Result textarea shows the template filled with rolled values.
- Save section writes the filled result as a new vault note in the configured results folder.
-
RegionModal(src/RegionModal.ts, extendsModal) — Manage hex map regions:- Switch the active region displayed on the hex map.
- Create, rename, and delete regions. Regions map to subfolders under
hexFolder.
-
TerrainEntryEditorModal(src/TerrainEntryEditorModal.ts, extendsModal) — Edit a single terrain palette entry: name, color, icon (with preview), and icon color tint. Changes are staged in pending fields and only written to the palette on Save. -
TerrainPickerModal(src/TerrainPickerModal.ts) — Full terrain palette picker (no row cap). Includes "Clear" to erase terrain. -
IconPickerModal(src/IconPickerModal.ts) — Full icon picker with image previews. Includes "Remove" option. -
FileLinkSuggestModal(src/FileLinkSuggestModal.ts) — Reusable fuzzy-search file picker scoped to a configured folder. -
DuckmageSettingTab(src/DuckmageSettingTab.ts) — Settings UI:- "Generate folders" button — fills blank folder settings with defaults under
worldFolderand creates the folders. - Folder paths: world, hexes, towns, dungeons, quests, features, factions, tables, workflows.
- "Generate terrain tables & hex links" button — runs
ensureTerrainTables,ensureAllRollerLinks,backfillTerrainLinks. - Hex orientation, grid dimensions, hex gap, custom icons folder, road/river colors, terrain palette editor (drag-to-reorder, click entry to open
TerrainEntryEditorModal).
- "Generate folders" button — fills blank folder settings with defaults under
Data model
- Hex notes:
{hexFolder}/{region}/{x}_{y}.md. Created viacreateHexNote(x, y, region)using the configured template ordefaultHexTemplate.md. - Template placeholders:
{{x}},{{y}},{{title}}. Template must include### Towns,### Dungeons,### Features,### Quests,### Factions,### Encounters Tableheadings. - Terrain: YAML frontmatter
terrain: <name>. Read viagetTerrainFromFile(metadata cache); written viasetTerrainInFile(raw text patch). Both infrontmatter.ts. - Icon override: frontmatter
icon: <filename>. Read viagetIconOverrideFromFile; written viasetIconOverrideInFile. - Terrain icons: plugin
icons/folder + user custom icons folder →plugin.availableIcons. URLs viagetIconUrl(plugin, filename). Vault-sourced filenames tracked inplugin.vaultIconsSet. - Roads & rivers:
settings.roadChains/settings.riverChains— arrays ofstring[]chains, each element"x_y". Drawn as SVG polylines over the grid. - Link sections (
LINK_SECTIONS):"Towns" | "Dungeons" | "Features" | "Quests" | "Factions" | "Encounters Table". Wiki-links inserted under the matching###heading viaaddLinkToSection. Read back viagetLinksInSection. - Text sections (
TEXT_SECTIONS): Description, Landmark, Hidden, Secret. Free text stored under###headings. Weather and Hooks & Rumors are also text sections but not in theTEXT_SECTIONSconstant. - Random tables: Markdown files with YAML
dice: Nfrontmatter and a| Result | Weight |table. Parsed byrandomTable.ts. Stored undertablesFolder(defaultworld/tables). Terrain tables live at{tablesFolder}/terrain/{name} - {description|encounters}.md. - Workflows: Markdown files with YAML frontmatter (
results-folder,template-file) and a| Table | Rolls | Label |steps table. Stored underworkflowsFolder. Templates stored at{workflowsFolder}/templates/{name}.md. Parsed/serialized byworkflow.ts. - Settings (
data.json):worldFolder,hexFolder,townsFolder,dungeonsFolder,questsFolder,featuresFolder,factionsFolder,tablesFolder,workflowsFolder,iconsFolder,templatePath,hexGap,hexOrientation,terrainPalette,gridSize,gridOffset,zoomLevel,roadChains,riverChains,roadColor,riverColor,defaultTableDice,regions.
Hex orientations
- Pointy-top: points north/south, flat sides east/west. Odd rows offset right. Adjacency via odd-r offset.
- Flat-top (default): flat sides north/south, points east/west. Odd columns offset down. Adjacency via odd-q offset.
hexNeighbors(x, y)inHexMapViewbranches onsettings.hexOrientation.
Key conventions
- Folder paths normalised (no leading/trailing slashes) via
normalizeFolder(). - Links use vault-relative paths via
metadataCache.fileToLinktext(file, sourcePath). obsidianis an esbuild external — never bundled; provided by Obsidian at runtime.- CSS classes use the
duckmage-prefix (styles instyles.css). - View/modal/tab files use
import type DuckmagePluginto avoid circular runtime dependencies. - Paint tool methods (
onHexPaintClick,onHexIconClick) are synchronous — DOM patched immediately, file writes queued via coalescing queue. - Link-tool click handlers (
onHexTableLinkClick,onHexFactionLinkClick) are async — no coalescing needed (one-shot writes). onChangedcallback fromHexEditorModaldefersrenderGrid()300 ms when no terrain/icon overrides are passed (link-only changes) to avoid a metadata-cache race aftervault.modify.- Modals that preload file content accept an optional
preloaded/initialContentparameter — callers that already hold the file content pass it in to avoid a redundant vault read. - Exclude notes whose
basenamestarts with"_"whenever enumerating vault files for display, dropdowns, or linking. Pattern:.filter(f => !f.basename.startsWith("_")). - All modals must extend
DuckmageModal(src/DuckmageModal.ts) rather than Obsidian'sModaldirectly.DuckmageModalis the single place for shared modal behaviour. Any functionality needed by more than one modal belongs there, not inutils.tsor duplicated across files. - All modals call
this.makeDraggable()inonOpen(inherited fromDuckmageModal), which adds theduckmage-editor-modal-dragclass and locks dragging to the title-bar area. (FileLinkSuggestModalis the sole exception — it extendsSuggestModaland cannot inherit fromDuckmageModal.) - NEVER interleave layout reads (
offsetWidth/Height/Left/Top,getBoundingClientRect,getComputedStyle) with DOM writes (createDiv/createEl/appendChild/setCssProps/style changes) inside a per-hex loop. Each read after a write forces a full synchronous layout flush → O(n²) thrash. On large maps (chult = 3843 hexes) this stalled the main thread for ~20s on open (regression inrenderCoordLabelsLayer, commit 10475fd; fixed by batching). Pattern: read ALL geometry into a plain array first, THEN do all DOM writes. This is the established pattern inrenderPathOverlay/renderFactionOverlay(buildcenterMapread-only, then build the SVG). The overlay/label builders inHexMapVieware the hot path — any new one MUST follow read-then-write. Reproduce/validate withdev/coord-label-thrash-bench.{html,mjs}(~198× speedup batched vs interleaved).
Obsidian reviewer-bot conventions (enforced beyond what eslint catches)
The obsidianmd reviewer flags patterns the locally-installed
eslint-plugin-obsidianmd doesn't have rules for. Watch for these
in any new code; failing these means another bump-and-fix cycle.
document/window→activeDocument/activeWindow. Required for popout-window compatibility. Globals are listed ineslint.config.mjs. UseactiveDocument.createElementNS(...),activeDocument.body.createDiv(...),window.setTimeout(...)is OK but preferactiveWindow.setTimeout(...)inside view code where popouts could fire it.- Dynamic CSS: never
.style.foo = …orsetCssProps({ transform: … }). The local eslint rule (obsidianmd/no-static-styles-assignment) is promoted to an error to catch this. The pattern is: declare thetransform/opacity/ etc. in CSS usingvar(--duckmage-..., default)fallbacks, and write the dynamic values to the custom properties from JS. See.duckmage-bg-image-layer,.duckmage-hex-map-grid,.duckmage-hex-map-viewportfor the canonical examples. setDynamicTooltip()is deprecated. The slider value is now always shown inline. Drop the call from any new.addSlider(...)chain.