AGENT_GUIDE.md was ~33k tokens, loaded in full by every agent that followed AGENTS.md's 'read it first', whatever the task. A third of it was lookups an agent can get from the code (file map, route table, env var table, settings key list, static asset list) and had already gone stale in several places. - AGENTS.md (~1.8k tokens, always loaded): what the project is, a 'before you touch X, read Y' table, the rules that always apply, workflow (including that a push to master publishes the Docker image), and the traps that have each broken real data or a real phone. It keeps the tool-managed graft block as is. - docs/agents/*.md (11 files, read on demand): the hard-won 'why' and 'do not regress' contracts by topic, sliced from the old guide with nothing hand-rewritten. The derivable lookups are dropped; the few non-derivable facts from them (git-ignored dev compose, built bundles, reference.docx) moved to testing-and-dev.md. - pointer comments at the top of the contract-heavy source files, so the rule surfaces at the point of editing - tests/agentDocs.test.js keeps the docs honest: AGENTS.md has a size budget, every topic file is linked and says when to read it, and any file, function or npm script a doc names must still exist - README and two code comments no longer point at AGENT_GUIDE.md
16 KiB
Editor model
Read this before: changing the editor (CodeMirror 6 markdown mode, TinyMCE rendered mode, mode switching, autosave, markdown round-trip) or debugging a phantom "Edited" state. This file records why and what not to regress. It deliberately does not say where code lives (ask the code index, or grep): maps go stale, reasons do not.
Architecture (dual-mode: CM6 markdown + TinyMCE rendered)
The editor supports two modes, both backed by the hidden <textarea id="note-body"> form field:
- Rendered mode = TinyMCE 8. Persistent singleton (
initPersistentTinyMCE()inpublic/app.js) mounted on a hidden<textarea id="tinymce-editor">that lives outside.app/#mobile-appinpages.jsso htmx swaps don't destroy it; aposition:fixed#tinymce-hostdiv is repositioned viapositionTinyMCEHost()to sit over the#tinymce-slotplaceholder ineditorFragment(). TinyMCE content is converted back to markdown viatinymceToMarkdown()(Turndown) into#note-bodyoninput/change. - Markdown mode = CodeMirror 6. Mounted into
#cm-hostbymountMarkdownEditor()→initCM(), seeded from#note-body. CM6 edits sync into#note-bodyviacmSyncToTA()(called from initCM's update listener).getCM()returns the liveEditorView;cmSetVal()replaces the CM document. codemirror.min.jsis loaded on the page beforeapp.js(built fromcm-build/,npm run build:cm), sowindow.CMis available for both the markdown editor and the code-block modal.
Text-expander is now wired for BOTH modes:
- Markdown mode (CM6):
maybeExpandTextFromCM()oninitCM()contentDOM input listeners (text + AI triggers). - Rendered mode (TinyMCE):
maybeExpandTextFromTinyMCE()oneditor.on('keyup'); inspects the caret text node suffix in the iframe and replaces the trigger viareplaceTinyMCETextExpansion()(multi-line →<br>, thentinyMCESyncToTA()). Bothaction:'text'ANDaction:'ai'triggers now fire in rendered mode: AI triggers callremoveTinyMCETriggerForAction()thenrequestTinyMCEProseCompletion()(builds a prompt from the iframe caret viagetTextBeforeCaretTinyMCE(), callsrequestProseCompletion()). The completion is offered in the SAMEnote-autocomplete-popupused by markdown mode — kind'tinymce-prose', accept with Enter/Tab inserts viainsertProseCompletionTinyMCE()(DOM text nodes, restores a caret bookmark first), Esc discards.Ctrl/Cmd-Spaceinside the iframe is wired oneditor.on('keydown')(the globaldocumentkeydown can't see iframe keystrokes) and also shows the popup. Popup keys are forwarded from the iframe keydown viahandleRenderPopupKey()because iframe key events never reach the outer-document listener; popup coords come fromtinyMCECaretCoords()(iframe caret rect offset by the iframe element rect).
Dead code and legacy paths (verified still present)
- Dead
getPV()/#note-previewcontenteditable code still exists inpublic/app.js(superseded by TinyMCE).getPV()returns null (the element is never rendered by any template), so everyif(pv){...}branch in the formatting helpers (wrapSel,insertPfx,clearFormat,openCodeModal,submitCode,syncPV,replacePVTextExpansion, etc.) is dead and always falls through to the CM/textarea branch. Harmless. NOT removed because it is threaded through ~30 functions and ripping it out risks regressing the live CM path; do it as a dedicated, well-tested cleanup pass, not a drive-by. - Image download buttons live in the TinyMCE body, not the dead PV path.
initResourceImageDownloadButtons/activatePVare legacy-only (PV never renders). The restored button isinitTinyMCEImageDownloadButtons(editor), called from_setTinyMCEContent's post-load hook next toinitTinyMCECodeCopyButtons. Serialization contract (same as the code-copy button): the button istextContent=''+data-mce-bogus="all"(label via CSS::afterin_tinyMCEContentFontStyle()), sogetContent()drops it — it can never reach saved markdown, HTML/DOCX/PDF exports. Only the positioning wrap<span class="preview-img-download-wrap">survivesgetContent();_stripTinymceDownloadChrome()unwraps it intinyMCEContent()andtinymceToMarkdown()so every serialization boundary sees the pre-injection DOM (keeps round-trip hashes stable — no phantom edits). Because the anchor can now sit inside the iframe,_positionResourceActionsresolves anchor rects via_anchorRectInHostDoc()(iframe-viewport rect offset by the iframe element's rect). Coverage:tests/tinymceImageDownload.test.js. highlightTinyMCECodeBlocks()is unreferenced dead code. It was the hljs pass over the TinyMCE body before Prism/codesampletook over; every call site was removed but the function is still defined (public/app.js). It is the only hljs consumer besides the dead PV path, so removing it (withhighlightCodeBlocks()) is a safe, self-contained slice of the PV cleanup.
Two modes
- Markdown mode: CodeMirror 6 mounted in
#cm-hostis the visible editor;#note-bodyis the hidden sync target. If the CM6 bundle fails to load,mountMarkdownEditor()falls back to showing the raw textarea. - Rendered mode: TinyMCE (persistent instance, positioned over
#tinymce-slot) is visible;tinymceToMarkdown()converts edited HTML back to markdown oninput/change.
Source of truth during editing
- The hidden textarea
#note-bodyis the form field used for saves. - In markdown mode, CM6 changes sync into
#note-bodyviacmSyncToTA(). - In rendered mode, TinyMCE's
getContent()is converted viatinymceToMarkdown()into#note-body. - Switching modes: markdown→rich calls
cmSyncToTA()then POSTs the markdown to/fragments/previewand loads the rendered HTML into TinyMCE; rich→markdown callstinyMCESyncToTA()then mounts CM6 from the textarea. There is no client-side markdown→TinyMCE-HTML converter — that direction round-trips through the server. - File/image uploads should alter markdown first, then refresh rendered preview from markdown; do not treat preview-only DOM insertion as authoritative state.
- The title is mirrored between
.editor-title, hidden title input, and mobile title header when applicable.
Save lifecycle
markEdited()updates UI state toEditedscheduleSave()triggers delayed autosave for body/form changesscheduleSaveTitle()is a shorter timer for title changes- If
scheduleSave()orscheduleSaveTitle()sees the same form hash as_savedHash, the visible save state should return toSaved, not remainEdited flushSave()is the forced-save path used before leaving a dirty note; it must also handle vault-note encryption before navigation proceedshtmx:afterRequeston the editor save path transitions UI state back toSaved- Offline/request failure paths set status to
Offline
Important fragility points
- DOM IDs and class names are part of the editor contract with inline JS (
#cm-host,#note-body,#tinymce-slot,#tinymce-host,#editor-toolbar). - Rendered-mode HTML (TinyMCE content) must remain convertible back to markdown with acceptable fidelity via
tinymceToMarkdown()/Turndown. - Checkbox, code block, and blank-line handling are easy to regress.
- Blank-line markers between blocks are
<p class="md-blank-line"><br></p>, NOT bare<div><br></div>.injectBlankLineBlocks()(app/markdownRenderer.js) emits extra blank lines as empty paragraphs because TinyMCE's schema preserves empty<p>natively; a bare<div><br></div>got normalised/merged/dropped around block-level images, which swallowed spacing between images after a few markdown⇄render round-trips. The TurndownblankLinerule (public/app.js, and the preview-path copy intests/previewRoundTrip.test.js) matchesP|DIV.md-blank-line; ingetTurndown()bothblankLineandemptyPemit the same\x00BL\x00sentinel so precedence is moot. Two TinyMCE quirks made image spacing collapse anyway (both fixed): (1) TinyMCE strips the<br>from the marker onsetContent, leaving an empty<p class="md-blank-line"></p>that Turndown drops — sotinymceToMarkdown()pre-normalises a blankmd-blank-lineparagraph (empty, whitespace/ -only, or just<br>) to the❤BR❤sentinel shape before Turndown. This normalisation MUST stay conditional. A marker is a real, focusable paragraph in the iframe, so clicking the gap between two blocks puts the caret inside it and typing puts the new text there; rewriting markers unconditionally (the old "empty or not" behaviour) replaced that text with the sentinel and silently destroyed it — type a line after a checklist, leave the note, come back, gone. When a marker holds real content, leave the paragraph verbatim: with text present neither theblankLinenor theemptyPrule matches, so it converts as an ordinary paragraph. Coverage:tests/appRuntime.test.js"keeps text typed INTO a blank-line marker (data-loss regression)" + "still collapses a genuinely blank blank-line marker". (2) Sized/raw-HTML images (Turndown emits<img … width=… />for resized images) are markdown-it HTML blocks rendered OUTSIDE any<p>; a loose block<img>next to markers gets absorbed into an adjacent paragraph by TinyMCE, sopostProcess()wraps any line that is a lone<img>in its own<p>. Regression coverage:tests/appRuntime.test.js"image spacing …", "sized … survive 6 mode switches", "br-stripped … marker". - The code modal is outside the fragment-swapped editor so it survives swaps; it uses CM6 (
_initCodeModalCM) which requireswindow.CM(now loaded). - Both markdown mode (CM6) AND rendered mode (TinyMCE) open this same custom full-screen CM6 code modal (
openCodeModal/submitCode) for editing the code text/language, NOT TinyMCE's built-incodesampledialog. The toolbar uses a customjop_codebutton; clicking an existing<pre>in rendered mode routes throughtinyMCEInsertCodeBlock()→openCodeModal(). On submit in rendered mode,submitCode()(TinyMCE branch,_codeTinyMCE/_codeTinyMCEBookmark) inserts<pre class="language-x">code</pre>viaed.insertContent()— thecodesampleplugin'sSetContenthandler then highlights it with Prism. Do NOT reintroduce hljs highlighting of rendered-mode blocks; Prism owns rendered-mode coloring.highlightTinyMCECodeBlocks()(the old hljs pass over the TinyMCE body) has no call sites left — the function body is dead, not deleted, so grep hits are misleading. Do not reintroduceed.execCommand('mceCodeSample')(that opens the built-in dialog). - On htmx editor-panel swap,
_cmViewis destroyed inhtmx:afterSwapand re-mounted byinitEditorPanel()(viamountMarkdownEditor) onhtmx:afterSettlewhen the note opens in markdown mode. Keep that destroy/remount ordering intact. #tinymce-hostisposition:fixedand repositioned viapositionTinyMCEHost(); if it looks detached, check that function and the#tinymce-slotrect, not CSS alone.- Turndown expels "flanking" whitespace, and it gets that wrong next to atomic children.
flankingWhitespace()derives an element's edge whitespace fromnode.textContent, which skips<img>/<br>(they contribute no text). For<a><img/> Label</a>it reports leading whitespace even though the whitespace is interior to the produced markdown ( Label), soreplacementForNode()'scontent.trim()cannot remove it — yet it is still prepended. The space was therefore duplicated on every round-trip and grew one character per note open, corrupting the stored body.tinymceToMarkdown()hides such whitespace behind a sentinel (_protectInlineLeadingSpace→_restoreProtectedSpace) that encodes the character code: these runs are frequently NBSP, and restoring a generic' 'would itself change the body and keep the note permanently dirty. Do not "simplify" that sentinel back to a plain space, and do not narrow its character class to[ \t]— Turndown'sedgeWhitespaceuses\s, which matches NBSP. - Soft breaks inside a blockquote must re-apply the
>prefix.> b\n> cis ONE quoted paragraph with a soft break, so the renderer emits<blockquote><p>b<br>c</p></blockquote>. The❤BR❤sentinel is restored to\nafter Turndown has prefixed the lines it emitted, so a naive globalsplit/joinproduced> b\nc— the second line escaping the blockquote entirely (real content corruption plus a permanent dirty-on-open diff). The restore is line-aware and carries the leading>run across the break; keep it that way. Bare>lines also get their insignificant trailing space stripped.
Debugging
If toolbar/mode behavior is inconsistent
- Verify whether the current editor is actually inside
#mobile-editor-body - Check
syncEditorModeButtons()andsetEditorMode() - Check whether the note was initialized with the expected
noteOpenMode - If switching modes marks the note
Edited, confirm the current form hash differs from_savedHash; unchanged hashes should showSaved
If title UI drifts
- Check
.editor-title - Check hidden input
.editor-title-hidden - Check
#mobile-editor-title - Check
autoTitle()andsyncTitle()
If save-state UI drifts
- Check
setSaveState() - Check
#autosave-status - Check
#mobile-editor-status - Check htmx save success/failure handlers and upload progress handlers
If a note shows "Edited" (or autosaves) immediately on open
_savedHash is snapshotted in initEditorPanel() from the raw server body, before CM6/TinyMCE has loaded. 820ms after setContent, the reconcile timer in _setTinyMCEContent() syncs the editor back to markdown and compares hashes. So any non-idempotent markdown⇄HTML round-trip looks like a user edit.
With debugLogging on, the reconcile prints exactly which side it took — this is the fastest way to triage:
| Log line | Meaning |
|---|---|
post-load reconcile: form clean, nothing to do |
Round-trip is idempotent. Save state is not the problem; look elsewhere. |
post-load reconcile: load normalisation only, re-baselining hash |
Round-trip is lossy but nobody typed. Hash is re-baselined: no markEdited, no save. Expected for notes with constructs HTML cannot represent. |
post-load reconcile: user typed during window, marking edited |
A real edit landed inside the 800ms quiet window and is being saved. Correct behaviour. |
If you see user typed during window when the note was untouched, the _tinymceUserTypedSinceLoad flag is being set spuriously — it must only be wired to keydown/paste/cut/drop. Never wire it to input or SetContent: editor.setContent() fires both, which reintroduces the phantom edit. Navigation-only keys (arrows, modifiers) must stay filtered out.
To find out why a note is non-idempotent, round-trip its stored body offline:
renderMarkdown(body) → tinymceToMarkdown(html) → diff against body. tests/appRuntime.test.js has the harness (makeTurndownCtx() + runWithDeps()) for running the real public/app.js functions under JSDOM.
Known-lossy constructs that are by design and will always re-baseline (do not "fix" them by adding markers):
- an indented
```fence loses its indent (HTML cannot carry fence indentation) - encrypted/vault note wrappers strip HTML comments — irrelevant in practice, since locked notes never load plaintext into TinyMCE
Heading-gap blank lines around ATX headings are no longer lossy: tinymceToMarkdown(html, prevMd) keeps Turndown's natural spacing and only falls back to the authored markdown (prevMd, what #note-body already held for the note) when the fresh conversion differs from it purely in heading-gap shape — collapsing both with _applyHeadingSpacing() yields the same text. Authored blank lines around headings survive the switch; untouched compact notes don't gain a phantom "Edited"/save; a real edit always wins (and one-time re-spaces a compact heading note). Do not reintroduce the unconditional headingGapRe/headingLeadRe collapse in tinymceToMarkdown() — it ate blank lines the user typed around headings (reported: add a blank line under a heading in markdown mode, switch to rendered and back, it was gone, and the collapsed body was saved). _applyHeadingSpacing() remains for the authored-gap comparator and the legacy preview path (htmlToMarkdown). Coverage: tests/appRuntime.test.js heading-gap round-trip tests + playwright-tests/heading-gap-roundtrip.spec.js.