Imported from aescanes/scribe-of-lagash-visualization (
AGENTS.md). Install upstream withnpx skills add aescanes/scribe-of-lagash-visualization. Copyright stays with the author.
AGENTS.md
Guidance for AI agents working on this repository. Read this before making changes.
Concept
Scribe of Lagash - Visualization is an Obsidian plugin that helps writers
visualize their chapters and scenes. It is the first plugin in the
"Scribe of Lagash" series — a set of independent, single-concern Obsidian
plugins for planning and writing stories. The series shares one per-note
frontmatter vocabulary, scribe-note-* (date, characters, locations,
status, …), so a note's metadata means the same thing to every plugin and is
written once; this plugin's key list is centralized in
src/types.ts. A file the plugin owns (the Lines / Outline
files) is tagged with a scribe-visualization: marker instead, since
that one is specific to this plugin.
Core principle: the plugin does not own the user's prose. Chapters and scenes are ordinary Markdown notes in the vault. The plugin discovers and reads them; it never rewrites an existing note's body or frontmatter. What it does write:
- the per-story Lines file (
Lines.md) — its own document, rewritten freely; - the per-story Outline file — created with an empty skeleton only when the user names it in settings and clicks the "Create" button there, then never touched again (hand-edited only);
- new chapter/scene notes, only when the user clicks a placeholder card to materialize a planned row (create-only; an existing note is never modified).
Standing preference (from the maintainer): favor folder structure over frontmatter keys and settings, to keep the plugin simple. Propose a folder-based approach before adding a new frontmatter field or setting.
Nomenclature
- In some point of the project we start using in the documents and UI the word
storyinsteadnovelorbookandwriterinsteadnovelist. The code remains using the old nomenclature to avoid a big refactor of the code.
Views of a story
- Line view — the default view, and everything built so far. Chapters/scenes
are discovered from a story folder by parsing note titles; the user creates
lines (horizontal colored tracks) and drags each card onto a line. The
arrangement is saved in the Lines file.
LineView,VIEW_TYPE_LINE_VIEW, ribbon icon / "Open lines" command. - Characters view — a read-only alternative to the Line view, chosen with
the combo box at the top right of the same tab (view kept in the leaf's view
state, default Line view). One derived line per character, alphabetical,
built by
charactersModelinviews/charactersModel.tsfromscribe-note-charactersand the Outline'sCharacterscolumn (union; a ⚠ mark when both list names and differ). Cards sit on the same manuscript-order columns; a scene naming two characters appears on both lines (derived, never saved — the one-card-one-line rule is for Lines.md). No drag, no edit controls, ghost cards non-clickable, and it writes nothing (not evenLines.md). Plan:docs/feature-plans/characters-view-plan.md. - Locations view — the same read-only view, one derived line per
location, from
scribe-note-locationsand the Outline'sLocationscolumn (locationsModel; same union / ⚠ rules). Both views are thin wrappers overderivedLinesModelinviews/derivedLines.ts. Plan:docs/feature-plans/locations-view-plan.md. - Chronological view (planned) — orders the same chapters/scenes by their
scribe-note-date, and only works for notes that have that property. Not built yet.
The term is line, not "timeline". An earlier draft called these timelines and also let one card sit on several at once — that multi-membership idea was prototyped and dropped; one card belongs to exactly one line.
How chapters and scenes are discovered
- The user points the plugin at a story folder (e.g.
StoryorStory/The Silent City). The plugin scans it recursively. - Each note is classified by parsing its title (file basename).
enandespattern tables ship:Chapter 1/Ch. 1/Scene IV/Prologue,Capítulo 1/Cap. 1/Escena IV/Prólogo, … Roman numerals are decoded. Notes whose title matches nothing are surfaced in a "not recognized" list. - The scene → chapter relationship is folder nesting only — a scene note
lives inside its chapter's folder. No
parentfrontmatter key. A story uses one style throughout: chapters are all standalone notes, or all folders of scene notes — mixing misorders (all file-chapters sort before any folder-chapter's scenes). The supported layouts are documented in the README and in the commentensureOutlineFilewrites at the top of a new Outline file. - Manuscript order is folder structure, then title number — all of
Act I/…beforeAct II/…, and within a folder by the number in the title (byManuscriptOrderindata/manuscriptOrder.ts, used byvaultIndex.ts). Folder segments are compared level by level, by their embedded number when both siblings carry one (Chapter 2beforeChapter 10,Act IXbeforeAct X) and as plain text otherwise. Noorderfrontmatter key.
The Lines file (per story)
Lines and card placements live in one Markdown file inside the story folder
(default StoryLines.md, configurable). The .md is optional in the setting
(withMdExtension) and the plugin prefixes the name with (SL) on disk —
StoryLines and StoryLines.md → (SL) StoryLines.md — via withScribePrefix,
both in lineLayout.ts. Human-readable,
diff-friendly, travels with the story. Shape:
---
scribe-visualization: lines
lines:
- id: main
name: Main line
color: "#e06c75"
order: 0
- id: backstory
name: Alice's backstory
color: "#e5c07b"
order: 1
placements:
"Story/Chapter 1.md":
lines: [main]
x: 0
"Story/Chapter 2.md":
lines: [backstory]
x: 1
---
Free-text notes about the story can go in the body.
Rules:
- The plugin only writes this file (debounced, on drag/edit). It never edits chapter/scene note bodies.
- A newly detected chapter/scene with no placement is auto-added to the topmost line so nothing silently disappears.
The Outline file (per story)
An optional second file beside Lines.md: a hand-edited Markdown table for
planning chapters/scenes before the notes exist. Off by default; a name in the
Outline file name setting turns it on. The file is created only when the
user clicks the "Create" button in that setting (never automatically
from saveSettings, which fires per keystroke) — the skeleton is a marker
(scribe-visualization: outline), an empty header table, then a rendered column
guide below it (the guide sits after the data table so parseOutlineTable /
replaceFirstTable, which act on the first table, still target the data one).
The .md extension is optional in the setting and normalized by
withMdExtension() (in lineLayout.ts, shared with the StoryLines file name) —
Outline and Outline.md both resolve to (SL) Outline.md. The configured
name is (SL) -prefixed on disk, same as the Lines file. Columns: Act | Chapter | Scene | Line | Summary, plus optional
Folder | Date | Characters | Locations | Status (a legacy Places header is still read as Locations); Line is a line name/id from
Lines.md.
- Each row's expected note path is
<story>/<folder>/<Chapter n>.md(a scene row nests under<Chapter n>/);folderis theFoldercell, else"<Act label> <Act cell>", else nothing. AChapter/Scenecell may carry free text after its number (e.g.1 - The beginning), same as a note title —parseLeadingNumber(titleParser.ts) reads only the leading number for matching/ordering, while the cell's full text carries through into the expected path and ghost-card label. A row is fulfilled when a real note matches by that path or by same type + number. - Unfulfilled rows render as dashed placeholder ("ghost") cards on the line
their
Linecell names, spliced into the manuscript order the real cards imply. Clicking one (or the toolbar's "Create N planned notes") creates the note vianoteScaffold.tsand seeds its placement at that slot. - The
Linecolumn can create lines, but the view never does it on its own (aLinetypo the user then fixes would leave a stray line behind). WhenLines.mdalready exists, a ⟳ button in the toolbar appears whenever the outline names a lineLines.mdlacks; clicking it adds those lines (theme-accent, appended last) as one undoable step. When there's noLines.mdyet, the "Create lines from outline" prompt seeds it with one line per distinctLinevalue (starterLayoutFromOutline) and puts each real note on the line its row names. Either way it's additive only: no rename, recolor, reorder, remove, or moving an existing note's card. - Ghost cards are draggable like real ones: a drop writes a
Lines.mdplacement keyed by the note's future path, socanvasModelpositions it there instead of by manuscript order, and the note lands there when created. - A row's
Summaryshows on its card (ghost or real). A ⚠ mark appears when the row disagrees with reality: for a fulfilled row, the note's line / folder / type; for a ghost, aLinecell that's empty, names no known line, or was dragged away from.reconcileOutlinecomputes all of these intomarks(keyed by note path or expected path). The folder/file structure andLines.mdalways win — an existing note or line is never edited to match the table (adding a missing line from theLinecolumn is the one exception, and it's purely additive), and the plugin never rewrites the table itself (theGenerate outline from notescommand only fills a still-empty one). - Both the notes and the outline are re-read every time the view opens and on every index change.
Full design and build history:
docs/feature-plans/outline-file-plan.md.
Architecture
Entry point: src/main.ts → ScribeVisualizationPlugin.
| Piece | File | Responsibility |
|---|---|---|
| Plugin shell | src/main.ts |
onload wiring: registers the line view, ribbon icon, the "Open lines" / "Generate outline from notes" commands, settings tab; owns the index as a child Component; createOutlineFiles() writes the skeleton for the story folder, called only from the settings "Create" button |
| Types | src/types.ts |
FRONTMATTER_KEYS (single source of truth for key names), NovelEntry, ParsedTitle, Line, Placement, LineLayout, OutlineRow, PlannedEntry |
| Title parser | src/data/titleParser.ts |
Pure, no Obsidian imports: parseTitle(basename, lang); romanToInt / parseNumberToken (whole string must be the number) / parseLeadingNumber (number then anything, used for Outline table cells); availableLanguages / languageLabel; actLabel / unitLabel (words the Outline file builds folders/filenames from). LANGUAGE_PATTERNS has en + es — a new language is one entry there plus one in LANGUAGE_LABELS / ACT_LABELS |
| Outline helpers | src/data/outline.ts |
Pure, unit-tested: parseOutlineTable (first GFM table → OutlineRow[]), expectedNotePath, outlineRowType / outlineRowNumber / outlineRowText (scene cell's raw text, else chapter's), outlineLineNames (distinct Line cell values, first-appearance order), reconcileOutline (rows vs. real entries → planned ghost cards + previews + discrepancy marks + fulfilledPaths + unknownLines) |
| Outline file I/O | src/data/outlineFile.ts |
outlineFilePath (via withMdExtension + withScribePrefix), readOutline (marker-checked), ensureOutlineFile (writes the empty skeleton once, returns whether it did), writeGeneratedOutline (fills a still-empty table only) |
| Outline generation | src/data/outlineGenerate.ts |
Pure: generateOutlineTable(entries, layout) → a table body from existing notes; replaceFirstTable swaps it in, keeping other text |
| Note scaffold | src/data/noteScaffold.ts |
Pure: scaffoldNoteBody(planned) — starter body for a note created from a ghost card: only the frontmatter keys the row filled, then the Summary as the body (no # title heading — the filename is the title) |
| Vault / story index | src/data/vaultIndex.ts |
Scans notes under the configured story folder, keeps the title-parsed ones as a live NovelEntry[] sorted by byManuscriptOrder (folder, then title number), notifies via onChange. First scan waits for onLayoutReady + metadataCache "resolved"; also watches vault create/delete/rename, debounced. rebuild() is public. getStoryFolder() / getEntriesForBook() |
| Line-layout helpers | src/data/lineLayout.ts |
Pure: parseLineLayout (coerce loose YAML), lineFilePath, withScribePrefix / withMdExtension (name normalization shared by the Lines and Outline file settings), emptyLineLayout |
| Path breadcrumb | src/data/pathContext.ts |
Pure: folderContext(filePath, baseFolder) → folder segments shown under a card title |
| Lines file I/O | src/data/lineFile.ts |
readLineLayout / writeLineLayout for the per-story Lines.md (write preserves the note body via processFrontMatter, or creates the file) |
| Line render model | src/views/canvasModel.ts |
Pure, unit-tested: canvasModel(entries, layout, outline?) → lines + real/ghost cards + unplaced + plannedUnplaced; manuscriptColumns (each card's default column on the shared reading-order axis); every layout edit (moveCard — drop at an exact column, pushing a card already there and its right neighbours over, no compaction; alignToOutlineOrder — snap the board back to the Story Outline: ghost cards drop any dragged placement and return to the line their Line cell names; a real note whose row names a line that exists in Lines.md (OutlineReconciliation.fulfilledLineIds) moves onto that line too, the same way — a real note with no row, or whose row names no valid line, keeps its current line; every placed card's column snaps to reading order (offered only when a Story Outline exists); reconcilePlacements, applyPlannedPlacements, addLine / renameLine / recolorLine / moveLine / removeLine, cloneLayout, starterLayout, starterLayoutFromOutline — a first layout with one line per outline Line value, entries seeded onto the line their row names at their manuscript column). All layout math lives here, not in the view. |
| Characters view | src/views/charactersModel.ts |
Pure, unit-tested: charactersModel(entries, reconciliation) → a CanvasModel with one derived line per character for the read-only Characters view (needs OutlineReconciliation.fulfilledCharacters) |
| Locations view | src/views/locationsModel.ts |
Same as charactersModel for locations (needs fulfilledLocations). Both delegate to src/views/derivedLines.ts: derivedLinesModel(entries, plan, source) — name normalization, note ∪ outline merge + ⚠, manuscript columns, alphabetical lines, stable color, "none" strip |
| Story views | src/views/storyViews.ts |
Pure, unit-tested registry of the StoryLines tab's views (STORY_VIEWS): each StoryViewDef says whether it is editable, its empty/unplaced-strip wording, and its buildModel. The view never checks which view it is in — it reads the descriptor (isEditable(), def.buildModel). A new view (dates, …) is one entry here plus its model function; parseStoryView coerces saved view state |
| Line view | src/views/lineView.ts |
ItemView (VIEW_TYPE_LINE_VIEW). DOM + pointer-drag only: renders from canvasModel, calls the pure ops via mutate() (push undo snapshot → apply → debounced save → re-render). Reads Lines.md + the outline on open / story-folder setting change / index change; a toolbar ⟳ button (shown only when missingOutlineLines() is non-empty) adds the lines the outline names but Lines.md lacks, an "Align cards to Story Outline " button (shown only while outlineRows is non-empty) re-spreads cards onto their reading-order columns, both as one undoable mutate; creates notes from ghost cards via a confirm modal |
| Confirm modal | src/views/confirmModal.ts |
confirm(app, {title, body, cta}) → Promise<boolean> (Obsidian ships no confirm primitive) |
| Settings | src/settings/ |
Story folder, Line-file name, Outline-file name (empty = off) + its "Create" button, title language. settingsTab.ts's rows are defined once (settingRows()) and rendered by both getSettingDefinitions() (declarative, Obsidian 1.13+, makes settings show up in Obsidian's search) and display() (imperative fallback for older Obsidian) |
| Styles | styles.css |
Obsidian CSS variables only (var(--...)) — no hardcoded colors except user-chosen line colors from the Lines file. Canvas classes are .scribe-canvas-*; per-line color is --scribe-line-color |
Separation of responsibility
- The view is DOM only. Every layout mutation is a pure function in
canvasModel.ts, unit-tested. The view calls them throughmutate(). - Title parsing is a pure, isolated, per-language module — no Obsidian API
calls — so it stays trivially testable. A card's
xis a free column coordinate on one axis shared by every line (the manuscript / reading-order axis): cards line up across lines by that column, gaps between cards are allowed and preserved, and no mutation compacts a line.
Conventions (enforced — don't violate)
Obsidian plugin guidelines — check before every code change
Before adding or changing any code, verify it against the official Obsidian plugin guidelines. Obsidian's automated review enforces these and rejects releases that break them. The rules that bite most often here:
- Use
this.app, never a globalapp. - Resource cleanup: register listeners/intervals with
registerEvent(),registerDomEvent(),registerInterval(), oraddCommand()so they're torn down automatically. Do notdetachLeavesOfType()inonunload()— Obsidian removes the plugin's views itself, and detaching also loses the leaf's position. - No hardcoded inline styles. Put styling in
styles.csswith Obsidian CSS variables; from code, toggle classes, or usesetCssStyles()/el.style.setProperty()only for values computed at runtime. - DOM, not HTML strings. Build nodes with
createEl()/createDiv()/createSpan(); neverinnerHTML/outerHTML/insertAdjacentHTML. - Settings tab: no top-level heading, no word "settings" in section names,
sentence case, and section headers via
new Setting(el).setName(...).setHeading()— not<h1>/<h2>. - Vault access: look notes up with
getFileByPath()/getAbstractFileByPath()— don't scan every file to match a path (a full scan is only OK for discovery, asvaultIndex.tsdoes).normalizePath()every user-supplied path. Edit the plugin's own files withVault.process()/FileManager.processFrontMatter(); neverVault.modify()a note the user is editing. - Commands: no default hotkeys;
callbackfor unconditional,checkCallbackfor conditional,editorCallbackwhen it needs the active editor. - Workspace: don't touch
workspace.activeLeafor cache view instances — usegetActiveViewOfType()/getActiveLeavesOfType(). - Async:
async/awaitover.then()chains; a floating promise gets an explicitvoid.consoleoutput is errors only. - Mobile-safe: no Node/Electron APIs, no regex lookbehind (
isDesktopOnlyisfalseinmanifest.json).
npm run lint runs ESLint with
eslint-plugin-obsidianmd's
recommended config — the same rules Obsidian's plugin review runs, plus
typescript-eslint's type-checked rules on src/; keep it green.
npm run lint:css runs Stylelint with
stylelint-config-obsidianmd
on styles.css — the same CSS rules Obsidian's review runs (!important,
external url()s, etc.). .stylelintrc.json widens selector-class-pattern
to allow this codebase's BEM --modifier classes (e.g.
scribe-canvas-card--planned); a few other findings are suppressed inline
with a stylelint-disable-next-line and a reason where the flagged style is
deliberate (documented at each spot).
Other conventions
- Don't run
gitwrite commands. Never rungit add,git commit, orgit push— the maintainer stages, commits, and pushes by hand. Leave your changes in the working tree. When asked to supply a commit message, give the message text only and do not append aCo-Authored-By:trailer or any other attribution line. - Commit messages follow Conventional Commits
1.0.0:
<type>[optional scope]: <description>, e.g.feat: …,fix: …,chore: …,docs: …,refactor: …,test: …; a breaking change adds!before the colon or aBREAKING CHANGE:footer. This is what the release tooling and CHANGELOG expect. - Branch names reuse the same type as a prefix:
<type>/<short-kebab-slug>, e.g.fix/outline-file-partial-name,feat/chronological-view,docs/readme-outline-section. - Never hardcode a frontmatter key string literal. Reference
FRONTMATTER_KEYSfromsrc/types.ts. - Never modify an existing chapter/scene note's body or frontmatter. The plugin writes its own documents (the Lines file, and the Outline file's initial skeleton) and may create a new note from a planned outline row, but editing prose the user wrote is off-limits unless a task explicitly calls for it and the user has agreed.
- TypeScript with
strictmode. Avoidanywhere a real type exists. - Comments explain why, not what. Match the existing sparse style.
- Keep diffs focused — no drive-by formatting or refactoring mixed into a feature/fix.
- Use US English only, never British English — in code, comments, UI text,
docs, and commit messages:
colornotcolour,normalizenotnormalise,materializenotmaterialise,favornotfavour,behaviornotbehaviour,recognizednotrecognised,mathnotmaths. (Language names/labels for the shippedes/entitle patterns are unaffected.) - Every source file starts with the SPDX
MITheader + copyright line. - License is MIT — don't add dependencies under a copyleft (GPL/LGPL/…) or otherwise MIT-incompatible license.
Supply-chain rules
- All deps pinned to exact versions — no
^,~,latest(.npmrc→save-exact=true). .npmrc→ignore-scripts=true. Don't rely on dependency lifecycle scripts.esbuild's postinstall is opted back in only vianpm run rebuild:esbuild.- Prefer a small amount of first-party code over adding a dependency.
Commands
npm install
npm run prepare # activate Husky hooks — needed once, since ignore-scripts=true
# keeps `npm install` from running `prepare` itself
npm run dev # esbuild watch → main.js (inline sourcemap)
npm run build # tsc --noEmit type-check + minified production bundle → main.js
npm test # esbuild-compile tests/**/*.test.ts → .test-build, run node --test
npm run lint # eslint . — ESLint flat config using eslint-plugin-obsidianmd's
# recommended rules; src/ and tests/ also get typescript-eslint's
# type-checked rules (needs the TS project)
npm run lint:css # stylelint styles.css — stylelint-config-obsidianmd's rules
npm run validate # typecheck + test + lint + lint:css — what the pre-commit hook runs
A Husky pre-commit hook (.husky/pre-commit) runs
npm run validate before every commit. .husky/_/ is generated by
npm run prepare and git-ignored.
CI (.github/workflows/ci.yml) runs npm run build,
npm test, eslint, and stylelint on push/PR to main, on Node 24 (matching
@types/node). All must pass before a PR.
Tests use Node's built-in node:test — no test framework dependency. They
live under tests/, which mirrors src/: the spec for
src/data/outline.ts is tests/data/outline.test.ts and imports its subject
from ../../src/data/outline. esbuild.test.mjs transpiles
the tests/**/*.test.ts files (obsidian and Node builtins left external) into
.test-build/. Only pure modules with no Obsidian imports are unit-tested; keep
such logic in its own file (e.g.
src/data/lineLayout.ts split out from lineFile.ts)
so it can be imported without pulling in obsidian.
Testing changes in a real vault
Copy or symlink manifest.json, main.js, and styles.css into
<vault>/.obsidian/plugins/scribe-of-lagash-visualization/, enable in Community
Plugins, and reload after each rebuild. This repo itself lives inside a test
vault's plugin folder, so npm run dev already writes main.js in place.
Releasing
Maintainer-only, from a clean main; feature PRs never bump the version. Make
sure CHANGELOG.md's ## [Unreleased] section is complete, then
npm run version-minor (or -patch / -major), then git push --follow-tags.
Each wrapper is npm version <type> --ignore-scripts=false — the flag is
required, since .npmrc's ignore-scripts=true otherwise skips the hooks. The
version hook runs version-changelog.mjs (promotes
## [Unreleased] to ## [<version>] - <date>) then
version-bump.mjs (syncs manifest.json / versions.json);
the postversion hook runs version-tag.mjs (writes that
CHANGELOG section into the tag message). The release workflow then puts the same
CHANGELOG section (via release-notes.mjs) at the top of
the GitHub Release body, above the auto-generated "What's Changed" notes. Full
steps in CONTRIBUTING.md.
Frontmatter schema
Every key is optional — a note becomes a chapter/scene purely by its title,
its order by folder structure + the title number, its line membership by the
Lines file. These keys just add detail the cards can show, and are shared across
the Scribe of Lagash series (scribe-note-*):
| Key | Type | Use |
|---|---|---|
scribe-note-date |
string (free-form) | in-story date shown on the card; the coming chronological view will order by it |
scribe-note-characters |
string / list | card meta; the Characters view |
scribe-note-locations |
string / list | card meta; the Locations view (the old scribe-note-places is still read as a fallback, never written) |
scribe-note-status |
string | e.g. draft (not yet surfaced) |
There is no -type, -order, -timelines, or -parent key — deliberately.
VaultIndex coerces leniently: comma-separated strings → arrays,
empty/missing → null or [].
Docs to keep in sync
When behavior or schema changes, update: README.md, CHANGELOG.md
("Unreleased"), the relevant plan doc under
docs/feature-plans/ (one file per feature — e.g.
line-view-plan.md,
outline-file-plan.md), and
CONTRIBUTING.md if conventions change.
