I7T5/Edmund
GitHub定义Edmund Markdown编辑器的架构契约,强制在代码变更、重构或涉及TextKit 2渲染、存储同步前参考。核心包含storage与rawSource一致及仅属性渲染两大不变量,旨在解决delete-drift等深层缺陷,保障编辑体验稳定性。
安装全部 Skills
npx skills add I7T5/Edmund --all -g -y
更多选项
预览集合内 Skills
npx skills add I7T5/Edmund --list
集合内 Skills (29)
.agents/skills/edmund-architecture-contract/SKILL.md
npx skills add I7T5/Edmund --skill edmund-architecture-contract -g -y
SKILL.md
Frontmatter
{
"name": "edmund-architecture-contract",
"description": "The load-bearing design contract of the Edmund Markdown editor. Load BEFORE any non-trivial code change in this repo; when asking \"why is it built this way\"; before proposing a new mechanism, subsystem, or refactor; whenever you are tempted to insert\/strip display characters, use NSTextAttachment, touch NSTextView.layoutManager, store NSTextBlock\/NSTextTable attributes, or add a new overlay\/decoration; and before designing anything that syncs storage, selection, undo, or the viewport. Covers the two hard invariants (storage == rawSource; TextKit 2 only), the render pipeline, edit\/undo flow, the TextKit 2 drawing model, the read-mode contract, and the known weak points. Not for build\/run\/release mechanics, debugging triage, or live-repro drivers — see \"When NOT to use this skill\"."
}
Edmund architecture contract
Edmund is a native macOS Markdown editor with live preview: AppKit +
TextKit 2, SwiftPM, macOS 14+. Two targets (Package.swift):
| Target | Role |
|---|---|
EdmundCore |
Library: parsing, rendering, EditorTextView, all tests. Most work happens here. |
edmd |
Executable: NSDocument app shell, Settings (SwiftUI), menus. Note: edmd is the Mach-O binary name; the app is "Edmund". |
Project ambition (maintainer, 2026-07-05): product-first — "the CotEditor of Markdown editors". Bias toward polish of the editing experience over feature count. The hardest live problem class to date is delete-drift (caret/selection integrity, 6 investigation rounds); the costliest failures were delete-drift and undo/redo viewport drift. Every rule below traces to one of those scars.
Ground truth this file distills: docs/ARCHITECTURE.md (repo root).
Treat that doc as authoritative if the two ever disagree, and fix this skill.
Glossary (each term defined once)
- rawSource — the document's Markdown text, the single source of truth
(
EditorTextView.rawSource). - storage — the
NSTextStoragethe text view displays (EditorTextStorage,Sources/EdmundCore/TextView/EditorTextStorage.swift). - Block — one logical Markdown block (paragraph, heading, list run, code
fence, table, quote/callout run). Model:
Sources/EdmundCore/Model/Block.swift. - Active block — the block under the caret; it renders its raw markdown (delimiters visible/editable) while all others render styled.
- Recompose — restyling storage from
blocks(Sources/EdmundCore/TextView/EditorTextView+Composition.swift). - Fragment — an
NSTextLayoutFragment, TextKit 2's per-paragraph layout unit. Off-screen fragments have estimated heights until laid out. - Overlay — an image or stroked path drawn at a character's laid-out
position by the custom fragment class (see §4), replacing what
NSTextAttachmentwould do in TextKit 1. - Delete-drift — the bug class where
rawSource/storage/selection desync and every later edit lands the caret in the wrong place. Chronicle:docs/investigations/delete-drift-investigation.md. - IME composition — an input method's provisional "marked text"
(
hasMarkedText()), present in storage before the user commits it.
1. The two non-negotiable invariants
Break either and the editor misbehaves in subtle, delayed ways. Every design review starts here.
Invariant 1 — storage == rawSource (attribute-only rendering)
The displayed text storage is always character-identical to rawSource.
Rendering only ever adds/changes attributes. Delimiters (**, `,
[!note], …) are hidden, never stripped: hiddenFont (0.01pt system
font, Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:55) plus a
clear foregroundColor makes them invisible without touching the string.
Rationale. Identity mapping between display offsets and raw offsets means there is no offset-translation layer — caret math, selection, undo diffs, incremental reparse, and autosave all operate on one coordinate system.
Consequences you must respect:
| Consequence | Why |
|---|---|
No NSTextAttachment, ever |
TextKit 2 only honors attachments on U+FFFC (OBJECT REPLACEMENT CHARACTER), which rawSource never contains. Images, math, bullets, checkboxes, icons are drawn as overlays instead (§4). |
| No inserted display characters | A synthesized <br>, bullet glyph, or padding character would desync offsets. Use attributes (.kern, paragraph styles) or overlays. |
| Never mutate storage while IME is composing | During composition, storage holds marked text so the invariant is transiently false and didChangeText defers syncing. Styling that runs beginEditing/setAttributes/invalidateLayout mid-composition strands the marked text; the invariant then stays broken and every later edit drifts the caret. Every storage-touching styling path — including async ones scheduled before composition began — must guard !hasMarkedText(). |
The incident. The original delete-drift bug: an async restyle fired during
IME composition, stranded the marked text, didChangeText kept bailing on its
own guard, and the invariant stayed silently broken — caret drift on every
subsequent edit. becomeFirstResponder now resyncs from storage as a
catch-all. Full write-up: docs/investigations/delete-drift-investigation.md.
Invariant 2 — TextKit 2 only
Never touch NSTextView.layoutManager (the TextKit 1 NSLayoutManager
accessor) and never store NSTextBlock/NSTextTable attributes. Either one
silently and permanently reverts the view to TextKit 1 — no error, no log,
just different (and wrong-for-us) layout from then on.
Rationale. All custom drawing rides NSTextLayoutFragment subclassing
(§4), and viewport-based layout (only on-screen content laid out) is what
makes large documents fast. Both are TextKit 2 facilities; a TK1 fallback
kills them.
The tripwire. DEBUG builds observe
NSTextView.willSwitchToNSLayoutManagerNotification and assertionFailure
if the fallback ever triggers —
textKit1FallbackTripwire(_:),
Sources/EdmundCore/TextView/EditorTextView.swift:272-303. If you see that
assertion, some code path you touched used a TK1 API or attribute. Find it;
do not suppress the assert.
Corollary: Edit-mode tables cannot use NSTextTable. Alignment is done by
distributing slack via .kern on hidden pipe glyphs —
Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift.
2. Render pipeline
rawSource ──BlockParser──▶ [Block] ──styleBlock per block──▶ attributed runs in storage
│
└─ SyntaxHighlighter (swift-markdown walker
+ custom parsers: callouts, ==highlight==,
wikilinks, comments, footnotes, math,
backslash escapes, inline HTML tags)
| Stage | Where |
|---|---|
| Block splitting | Sources/EdmundCore/Parsing/BlockParser.swift — parse(_:previous:), parseWithDiff(...) |
| Span production | Sources/EdmundCore/Parsing/SyntaxHighlighter.swift + +Walker.swift / +WalkerInline.swift / +CustomParsers.swift |
| One-block render | styleBlock(_:cursorPosition:...), Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:151; per-feature extensions in Sources/EdmundCore/Rendering/ (Callout, Code, Image, List, ListMarker, Math, Table, WikiLinks) |
| Orchestration | Sources/EdmundCore/TextView/EditorTextView+Composition.swift |
Recompose entry points (pick the narrowest that works — a full recompose
resets every fragment height to an estimate, see §6):
| Function | Scope | Used for |
|---|---|---|
recompose(cursorInRaw:) |
Whole document | Load, indent — never for undo (§3) |
recomposeDirty(_:cursorInRaw:) |
A set of block indices, in place | The workhorse; attribute-only |
recomposeIncremental(cursorInRaw:...) |
The block(s) the caret moved between | Most cursor moves |
recomposeReplacing(oldRange:with:...) |
One contiguous text span | Undo/redo restore |
Lazy styling (Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift):
a large dirty set styles only the viewport synchronously; the rest is finished
by the idle drain (time-budgeted main-thread slices) and scroll
promotion (style blocks as they enter the viewport).
Gotcha: attribute-only changes do not re-measure geometry in TextKit 2.
If a restyle changed a block's height/indent, call invalidateLayout(for:)
on its range or the fragment keeps a stale frame. recomposeDirty and the
idle drain already do this; any new path must too.
3. Edit flow & undo
Normal edit: shouldChangeText (records a coalesced undo snapshot) →
NSTextView mutates storage → didChangeText syncs rawSource and restyles
the edited block(s) (Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift,
+Composition.swift). Edits capture a pendingEdit on EditorTextStorage
and reparse a window, not the whole document.
Undo/redo is custom (Sources/EdmundCore/TextView/EditorTextView+Undo.swift):
stacks of rawSource snapshots, bypassing NSTextView's built-in undo.
Restoring diffs the snapshot against current text (textDiff(old:new:),
single contiguous span) and applies it with the range-bounded
recomposeReplacing — never a full recompose, because a full recompose
resets every fragment to a TextKit 2 height estimate and the follow-up scroll
lands wrong (this was the undo/redo viewport-drift failure). The changed text
drives the viewport: hold if any of it is on-screen, else center it.
AppKit does NOT pair every storage mutation with didChangeText. Proven
incident (delete-drift round 4): a drag-move of selected text dropped on no
valid target deletes the dragged range via shouldChangeText →
replaceCharacters and never calls didChangeText — silently freezing
rawSource/blocks; every later edit drifts the caret and autosave writes
stale text. The heal: shouldChangeText schedules a next-run-loop
bypass check (scheduleBypassedEditSyncCheck, +EditFlow.swift) — a
pendingEdit still unconsumed by then means the closing didChangeText
never came, and the editor runs the same sync itself. Breadcrumb in
~/.edmund/logs: healing storage edit that bypassed didChangeText.
Never build a sync path on the assumption that didChangeText follows
every edit.
Round 6 corollary: a bypassed edit also leaves TextKit 2's private selection
fixup (_fixSelectionAfterChangeInCharacterRange:) queued; it fires at the
next endEditing — even an attribute-only restyle — and leaps the caret
blocks away, moving even a freshly set valid caret. The heal sets the caret
before the sync and re-asserts it after (+EditFlow.swift). This class
does not reproduce headless; see the routing in §8.
4. TextKit 2 drawing model
All custom visuals are drawn by DecoratedTextLayoutFragment (custom
NSTextLayoutFragment,
Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift:160), vended via
the layout-manager delegate. Two custom attribute keys (same file, lines
28/32):
| Attribute | Level | Draws | Rules |
|---|---|---|---|
.blockDecoration |
Paragraph | Callout boxes, quote bars, table borders, thematic-break rules, code backgrounds | Fragments tile vertically so a multi-line run reads as one continuous box/bar. A box's bottomPad grows the last fragment's own frame — TextKit 2 omits trailing paragraph spacing from the fragment, so padding done any other way is dead space. |
.fragmentOverlay |
Character | An image or stroked vector path at a character's laid-out position: rendered math, list bullets/checkboxes, callout header icon+name image, custom-title callout icon (path) | The anchor glyph is hidden (hiddenFont + clear color) and .kern reserves the drawing's advance width — the same trick the table renderer uses. |
The image-wedge constraint (open, not solved). Drawing an image overlay
on a multi-line (wrapping) fragment re-triggers a layout pass that wedges
the fragment to one line. Drawing a shape (stroked CGPath) does not.
That is why the wrapping callout custom-title icon is a stroked path parsed
from vendored Lucide geometry, never an image. Any new overlay that could
share a line with wrapping text must be a shape, not an image. Full saga:
docs/investigations/archives/callout-title-wrap-investigation.md.
5. Read mode contract
Read mode is a separate WKWebView, not an editor styling mode
(Sources/EdmundCore/Export/). The contract: one parser, two back-ends —
the same swift-markdown Document the editor parses is walked by
SyntaxHighlighter.SpanCollector (→ editor attributes) and by HTMLRenderer
(→ HTML), themed from the same EditorTheme via HTMLTheme, so the two
renderings cannot drift. When adding a feature, implement it in both
back-ends or document the divergence.
Hard properties of the web view (keep them):
- JavaScript disabled; every asset inlined (math as high-DPI PNG data
URIs — SwiftMath has no SVG path; icons as inline Lucide SVG) so it needs
no file/network reach. Remote images off by default
(
Sources/EdmundCore/Export/ReadRenderOptions.swift). - Private URL schemes route navigation without JS:
x-edmund-wiki:/x-edmund-link:(Sources/EdmundCore/Export/HTMLRenderer.swift:26,31). - Inline HTML: only the whitelist
SyntaxHighlighter.htmlFormatTags(u/kbd/mark/sub/sup,Sources/EdmundCore/Parsing/SyntaxHighlighter.swift:22) renders in either mode; everything else stays escaped/color-only. A real<br>break would need to mutate storage — forbidden by Invariant 1. - Export/Print run the same HTML through
WKWebView.printOperation(Sources/EdmundCore/Export/MarkdownPrinter.swift) for vector text.
6. Known weak points (open as of 2026-07-05)
State these plainly when designing near them; none is solved.
- TextKit 2 height estimates are the root of most viewport glitches: an
off-screen fragment's frame (and the total document height) is an estimate
until layout reaches it — scroller jumps, scroll-to-target lands wrong (a
documented TK2 limitation; TextEdit shows it too). Mitigations in place,
not cures: documents ≤
fullLayoutMaxLength(100k UTF-16,Sources/EdmundCore/TextView/EditorTextView.swift:80) are kept fully laid out byscheduleFullLayoutSettle()wrapped inpreservingViewportAnchor(+LazyStyling.swift:121,+TypewriterScroll.swift:22);repairContentAboveOrigin()(+LazyStyling.swift:151) fixes content stranded above y=0;centerViewportOnCaretre-measures after its first scroll. Never trust an off-screen fragment's y-coordinate without laying out the span first. - The image-wedge constraint (§4) applies to every new overlay.
- Open bugs (
misc/backlog.md): callout at end-of-file renders an extra un-prefixed line in the callout color (live incremental-restyle path, not static rendering); footnotes don't render in either mode; attached images create blank space below; math doesn't render in read mode (and has wrong padding in edit mode); delete caret drift and viewport-estimate glitches remain on the ongoing list. - Crash reporter endpoint is a placeholder:
CrashReporter.reportingEndpointishttps://REPLACE-ME.invalid/crash(Sources/EdmundCore/Diagnostics/CrashReporter.swift:27) and the Settings ▸ Advanced toggle is commented out (Sources/edmd/Settings/AdvancedSettingsView.swift). Do not treat crash uploading as live.
7. Before you design something new — checklist
Run this before proposing any new mechanism, subsystem, or refactor:
- Invariant 1: does it insert/strip characters, use
NSTextAttachment, or mutate storage outside the shouldChangeText→didChangeText path (or during IME composition)? If yes, redesign as attributes/overlays. - Invariant 2: does it touch
NSTextView.layoutManageror storeNSTextBlock/NSTextTable? If yes, stop. - Sync assumptions: does it assume
didChangeTextfollows every mutation, or that a set caret stays put across the nextendEditing? Both assumptions are proven false (§3). - Geometry: does it read an off-screen fragment frame, or restyle
without
invalidateLayout(for:)when height changed? (§2, §6.) - Both back-ends: does a rendering feature cover Edit and Read (§5)?
- Prior art: check ARCHITECTURE.md §14 — especially nodes-app/swift-markdown-engine, an independent AppKit+TextKit 2 live-preview engine solving the same problems — before inventing a new mechanism for an editing-experience problem.
- Weak points (§6): does the design lean on anything listed there? Label it as such; unproven mitigations are "open/candidate", never "fixed".
- Verification plan: unit test if headless can repro; otherwise plan
a live repro (ReproScript) — do not ship a caret/IME/viewport fix on
reasoning alone. Visual claims are measured from
screencapturepixels, not eyeballed.
Process rules live in sibling skills, but never contradict them here: branch
per fix off main; never auto-push/PR/merge; swift test green + visual
verification before commit; never blanket pkill -x edmd (the maintainer's
daily-driver app shares the binary name — pgrep and kill only your own
PID); never request macOS Computer Access permissions.
When NOT to use this skill
| You need… | Use instead |
|---|---|
| Whether/how to gate a change, commit discipline, scope control | edmund-change-control |
| A symptom → cause triage path for a bug you're seeing | edmund-debugging-playbook |
| The blow-by-blow history of a past investigation | edmund-failure-archaeology |
| TextKit 2 / AppKit theory beyond Edmund's specific contract | textkit2-appkit-reference |
| Launch flags, debug bundles, defaults keys | edmund-config-and-flags |
| Build, stale-binary cures, screencapture mechanics, environment setup | edmund-build-and-env |
| Cutting a release, Sparkle/appcast/CI | edmund-release-and-operate |
| Driving a live repro (ReproScript, CGEvent, log tracing) | edmund-live-repro-and-diagnostics |
| Test-writing patterns, QA passes | edmund-validation-and-qa |
| Docs style, ARCHITECTURE.md upkeep | edmund-docs-and-writing |
| Positioning, comparisons, marketing claims | edmund-external-positioning |
| The caret/selection-integrity campaign specifically | edmund-caret-integrity-campaign |
| How to investigate an unknown (method, not facts) | edmund-research-methodology / edmund-research-frontier |
Provenance and maintenance
Facts verified against the repo on 2026-07-05. If a grep below stops matching, the fact drifted — update this file and cite the new location.
# Invariant 2 tripwire still present
grep -n "textKit1FallbackTripwire" Sources/EdmundCore/TextView/EditorTextView.swift
# Recompose entry points
grep -n "func recompose" Sources/EdmundCore/TextView/EditorTextView+Composition.swift
# Bypass heal
grep -n "scheduleBypassedEditSyncCheck" Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
# Custom draw attributes + fragment class
grep -n "blockDecoration\|fragmentOverlay\|class DecoratedTextLayoutFragment" Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift
# hiddenFont hiding trick
grep -n "hiddenFont" Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift
# Viewport mitigations
grep -n "fullLayoutMaxLength" Sources/EdmundCore/TextView/EditorTextView.swift
grep -n "scheduleFullLayoutSettle\|repairContentAboveOrigin" Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift
# Read-mode schemes + HTML whitelist
grep -n "wikiScheme\|linkScheme" Sources/EdmundCore/Export/HTMLRenderer.swift
grep -n "htmlFormatTags" Sources/EdmundCore/Parsing/SyntaxHighlighter.swift
# Crash-reporter placeholder (delete §6.4 once this is a real URL)
grep -n "REPLACE-ME.invalid" Sources/EdmundCore/Diagnostics/CrashReporter.swift
# Open-bug list
sed -n '/^Bugs/,/^UI\/UX/p' misc/backlog.md
.agents/skills/edmund-build-and-env/SKILL.md
npx skills add I7T5/Edmund --skill edmund-build-and-env -g -y
SKILL.md
Frontmatter
{
"name": "edmund-build-and-env",
"description": "Build, environment, and toolchain runbook for the Edmund repo (native macOS Markdown editor; AppKit + TextKit 2, SwiftPM). Load this skill when: setting up the environment from scratch (fresh clone, new machine, CI mirror); a build fails or a \"successful\" build behaves stale (change \"doesn't take\", old code runs, Build complete! but nothing changed); the app crashes on launch or the instant it renders LaTeX; you need to construct the debug bundle (EdmundDbg.app) for live runs; or BEFORE trusting any binary you just built for a visual check or repro run. Covers swift build\/test, build-app.sh anatomy (codesign sealing order, SwiftMath bundle placement), the stale-build disease and its detection, safe launch\/kill hygiene around the user's live instance, and the CI environment."
}
Edmund — build & environment runbook
All paths relative to the repo root. Facts date-stamped 2026-07-05 are volatile — re-verify per the last section.
When NOT to use this skill
| You actually need | Go to |
|---|---|
| The storage==rawSource / TextKit-2-only invariants, render pipeline | edmund-architecture-contract |
| Branch/commit/PR rules, what you may touch | edmund-change-control |
| Diagnosing a bug (not the build) | edmund-debugging-playbook |
| ReproScript / CGEvent live-repro driving | edmund-live-repro-and-diagnostics |
| Launch flags, defaults keys, settings | edmund-config-and-flags |
| Cutting a release, DMG, Sparkle appcast | edmund-release-and-operate |
| Screencapture verification method, test policy | edmund-validation-and-qa |
1. Environment from scratch
Requirements (verified 2026-07-05):
| Tool | Version | Why | Check |
|---|---|---|---|
| macOS | 14+ | Package.swift platforms .macOS(.v14) |
sw_vers |
| Xcode | 16+ (full Xcode, not just CLT) | swift-tools-version: 6.0; build-app.sh needs actool |
swift --version (local: Swift 6.0.3) |
| gh CLI | any recent | releases, PR ops | gh --version |
| Node | ≥20 | releases only | node --version |
| create-dmg | npm package | releases only | npm install --global create-dmg |
create-dmg trap: install via npm, NOT Homebrew. brew install create-dmg
is a different tool with an incompatible CLI. Not needed for dev work —
only for cutting releases (see edmund-release-and-operate).
Dependencies are fetched by SPM on first build — nothing to install by hand
(verified against Package.swift / Package.resolved, 2026-07-05):
swift-markdown≥0.5.0 (CommonMark/GFM parsing; pullsswift-cmarktransitively)SwiftMath≥1.7.0 (LaTeX rendering — its resource bundle is a launch-crash landmine, §3)Sparkle≥2.6.0 (auto-update — its framework is a dyld-abort landmine, §4)
Two SPM targets: EdmundCore (library + all tests; most work happens here)
and edmd (the app shell executable). The binary is named edmd even
though the app presents as "Edmund" — deliberate, see the comment in
Package.swift.
2. Core commands
swift build # debug build of both targets
swift test # full suite: ~750+ tests, ~10s (2026-07-05)
swift test --filter Callout # one suite
./scripts/build-app.sh # release build → build/Edmund.app
swift test also runs automatically as a Stop hook after code-touching turns.
3. What build-app.sh actually does (and why the order matters)
scripts/build-app.sh → build/Edmund.app. Steps, in order:
swift build -c release- Assemble
build/Edmund.app/Contents/{MacOS,Resources}; copy.build/release/edmd,Info.plist,Resources/AppIcon.icns. - Compile
Resources/Assets.xcassetswithactool(falls back to/Applications/Xcode.app/.../actoolif xcode-select points at the CLT). - Embed
Sparkle.frameworkintoContents/Frameworks/(found under.build/; SwiftPM links Sparkle but never copies the framework — without it the updater crashes on first check) andinstall_name_tool -add_rpath "@executable_path/../Frameworks"so@rpathresolves post-install. - Codesign inside-out: Sparkle.framework first (nested XPC helpers must
be signed before macOS will launch them), then the whole
.app(ad-hoc,--deep, identifiercom.i7t5.edmd). Sealing the bundle — not just the binary — is what Sparkle's update validator requires. - Only after sealing: copy
.build/release/*.bundle(SwiftMath's math fonts) into the.approot.
Why step 6 is last and at the root — two constraints collide:
codesignrefuses to seal a bundle with any extra item at the.approot ("unsealed contents present in the bundle root"), so the seal must happen while the root holds onlyContents/.- SwiftMath's generated
Bundle.moduleaccessor hardcodesBundle.main.bundleURL— the.approot — with only a hardcoded absolute.buildpath as fallback. So the bundle must sit at the root.
Resolution: seal first, copy after. The one unsealed root item makes
codesign --verify and --strict complain, but Sparkle's actual check is
non-strict and tolerates it (verified end-to-end; details in
docs/ARCHITECTURE.md §8).
Missing SwiftMath bundle = instant crash the moment the app renders any
LaTeX. App launches fine, opens documents fine, dies on the first math
block. If you see that crash, check ls build/Edmund.app/*.bundle first.
4. Debug bundle fast path (EdmundDbg.app)
For live runs of a debug build, skip build-app.sh and hand-assemble
(from docs/dev-guides/live-repro-guide.md §4):
swift build
mkdir -p build/EdmundDbg.app/Contents/MacOS
cp Info.plist build/EdmundDbg.app/Contents/
cp .build/arm64-apple-macosx/debug/edmd build/EdmundDbg.app/Contents/MacOS/
cp -R .build/arm64-apple-macosx/debug/Sparkle.framework build/EdmundDbg.app/Contents/MacOS/
- Sparkle.framework must sit next to the binary — dyld aborts without it.
- A bare
.build/debug/edmdruns but never creates a window. It needs the bundle (Info.plist) around it. - Launch by direct exec of the bundle binary, never
open -a:
build/EdmundDbg.app/Contents/MacOS/edmd /path/to/test.md \
-ApplePersistenceIgnoreState YES &
LaunchServices (open -a) can silently run a stale cached/translocated
copy — you'd be executing last hour's code. Direct exec runs exactly the
binary you just copied. -ApplePersistenceIgnoreState YES stops state
restoration from reopening previous (possibly mutated) documents.
- Recreate the test document fresh before every run — autosave mutates it.
5. THE STALE BUILD DISEASE
The single most expensive trap in this repo: it has produced entire wrong debugging conclusions ("my fix doesn't work" when the fix was never in the binary).
Symptom: swift build prints Build complete! having compiled a changed
file but not relinked edmd. The app then runs old code. Release builds
(swift build -c release / build-app.sh) reuse stale objects too.
Detection — before trusting ANY binary you just built:
# 1. Grep for a LONG string literal unique to your new code:
strings .build/arm64-apple-macosx/debug/edmd | grep 'your long unique literal'
# 2. Hash before/after the build:
shasum .build/arm64-apple-macosx/debug/edmd
Literal length matters: string literals ≤15 bytes are stored inline in
the Mach-O on arm64 and never appear in strings output. A short probe
literal gives a false "stale" verdict. Use a long one (a distinctive log
message works well).
Cure:
swift package clean # first resort
rm -rf .build # visual change "doesn't take" → nuke it all
Never hand-delete .build/…/edmd.build/ — that corrupts SwiftPM's
output-file-map and wedges the target until a full clean anyway.
6. Running for visual checks — launch/kill hygiene
The user's daily-driver app has the same binary name (edmd). A blanket
pkill -x edmd kills their live session. Always, in order:
# 1. Who is running, and since when?
pgrep -lx edmd
ps -o lstart=,command= -p <pid>
# 2. Kill ONLY your own PID — or, if you launched the debug bundle:
pkill -f EdmundDbg
Other run gotchas:
open Edmund.appforegrounds a running instance instead of relaunching — you'll be looking at the old binary. Kill your instance first or direct-exec the binary.- Always pass
-ApplePersistenceIgnoreState YES(see §4). - After many rapid launch/kill cycles the window server can glitch (tiny
windows, broken state restoration):
rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedStateand relaunch. - Verification method (window-id screencapture, offscreen render fallback):
edmund-validation-and-qaanddocs/ARCHITECTURE.md§8.
7. CI environment
.github/workflows/ci.yml (verified 2026-07-05): runs swift test on
macos-14 with latest-stable Xcode (Swift 6.0 needs Xcode 16+), triggered
on PRs and pushes to main.
- SPM cache:
.buildis cached keyed onspm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }}. Thev2token exists because the repo renamemd→Edmundchanged the checkout path and invalidated absolute paths baked into the cached module cache — bump the token to discard a poisoned cache. - Concurrency:
cancel-in-progress: trueper branch/PR — private-repo macOS minutes bill at 10x, so superseded commits' runs are cancelled. - Release pipeline (
release.yml, tag-triggered) is a separate beast:edmund-release-and-operate/docs/ARCHITECTURE.md§13.
8. Checklists
Fresh clone to green
-
sw_vers— macOS 14+;swift --version— Swift 6.x (Xcode 16+) -
git clone+cd Edmund -
swift build— SPM fetches swift-markdown, SwiftMath, Sparkle -
swift test— ~750+ tests green in ~10s - Read
docs/ARCHITECTURE.md(mandated by AGENTS.md before non-trivial work) - Visual work planned?
./scripts/build-app.sh, confirmbuild/Edmund.appexists andls build/Edmund.app/*.bundleshows the SwiftMath bundle - Releases planned?
node --version≥20,npm install --global create-dmg
Before trusting any run
- Binary is fresh:
strings <binary> | grep '<long unique literal>'and/orshasumchanged since the edit (§5) -
pgrep -lx edmd— user's live instance identified; you will kill only your own PID (§6) - Launched by direct exec, not
open -a(§4) -
-ApplePersistenceIgnoreState YESpassed - Test document recreated fresh (autosave mutated the last one)
- Debug bundle: Sparkle.framework sits next to the binary
- Release bundle: SwiftMath
*.bundleat the.approot (or the first LaTeX render crashes)
Provenance and maintenance
Every claim above was read from the files below on 2026-07-05. Re-verify:
- Toolchain/deps:
cat Package.swift(tools-version, platforms, dep versions);grep identity Package.resolved - Build anatomy:
cat scripts/build-app.sh(step order, sealing comments) - Test count/time:
swift test 2>&1 | tail -3 - Debug bundle recipe:
docs/dev-guides/live-repro-guide.md§4 - Stale-build disease, launch gotchas:
docs/ARCHITECTURE.md§8 ("Stale release builds", "open Edmund.app", savedState) - CI facts:
cat .github/workflows/ci.yml(cache key comment, concurrency) - create-dmg quirks:
docs/ARCHITECTURE.md§8 ("create-dmg — npm only")
If a command here disagrees with those files, the files win — update this skill.
.agents/skills/edmund-caret-integrity-campaign/SKILL.md
npx skills add I7T5/Edmund --skill edmund-caret-integrity-campaign -g -y
SKILL.md
Frontmatter
{
"name": "edmund-caret-integrity-campaign",
"description": "The executable, decision-gated campaign for Edmund's hardest live problem: the delete-drift class (caret\/selection integrity in the live NSTextView \/ TextKit 2 \/ input-context layer). Load when the caret or selection lands in the wrong place after a delete\/type\/IME\/drag interaction, when text edits corrupt or desync, when autosave writes stale content, or when logs show ⚠︎LEN-MISMATCH or the \"healing storage edit that bypassed didChangeText\" breadcrumb. Runs round 7+ at the retiring maintainer's standard: classify, fence off six settled battles, capture evidence, build a deterministic repro, pick from a ranked solution menu with proof obligations, then validate and promote through change control. Not for viewport\/scroll drift (that's edmund-debugging-playbook → viewport docs) unless the caret itself moves."
}
Caret-integrity campaign (the delete-drift class)
Six rounds are settled; new rounds are expected. This class does not
reproduce in headless tests — the harness runs AppKit's deferred machinery
synchronously, so a green unit test proves nothing here. Success is measured by
PASS/FAIL grep and byte counts, never by eye or by reasoning.
Verified 2026-07-05 against docs/investigations/delete-drift-investigation.md (456 lines) and
the code. Read that doc fully before a deep dive.
QUICK CARD (the whole loop)
0. CLASSIFY grep logs for the 4 signatures. Not this class? → debugging-playbook.
1. FENCE check the 6 settled battles + guards still hold. Don't re-fight them.
2. EVIDENCE run with verbose diags; find FIRST bad line; walk BACKWARDS;
traceSelectionOrigin names who moved the caret; rebuild the doc.
3. REPRO ReproScript: needles not offsets; real events; replicate internal
call sequences verbatim. Freeze a script whose logsel/assertcaret
DISCRIMINATES broken vs current build. No repro → hypothesis wrong.
4. SOLVE rank candidates (missing guard < extend heal < reorder fixup <
structural). Each states what it must PROVE.
5. PROMOTE fix flips frozen repro to PASS same run → soak 4–5 cycles,
byte-identical rawLen → swift test green → add test (locks headless
contract even if non-discriminating) + keep .repro → update the
investigation doc → branch/commit via change-control. Never ship on
reasoning alone.
PHASE 0 — Classify: is it actually this class?
Grep ~/.edmund/logs/edmund-<date>.log (run the app with
-settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES):
| Signature | Meaning |
|---|---|
healing storage edit that bypassed didChangeText |
a bypass fired (round-4 class) |
persisting ⚠︎LEN-MISMATCH (not just transient between shouldChangeText→synced) |
storage/rawSource desync stuck |
selectionDidChange with up=Y at a surprising position |
selection moved mid-recompose (round-6 class) |
a shouldChangeText with no synced/SKIPPED/DEFERRED after it |
a bypassed didChangeText |
scripts/grep-trace.sh in edmund-live-repro-and-diagnostics surfaces all
four at once.
If none match and the symptom is scroll/viewport-shaped (jump, wrong
landing, can't-scroll-up) with the caret not leaping → this is the viewport
class, not caret integrity → edmund-debugging-playbook +
docs/investigations/viewport-glitch-investigation.md. Do not run this campaign on a viewport
bug.
PHASE 1 — Fence off the six settled battles
Confirm each shipped guard still holds before hypothesizing a new mechanism. The most likely round-7 shape is a new code path that lacks an existing guard, not a new mechanism.
| Round | Mechanism (settled) | The shipped guard — confirm it still covers your path |
|---|---|---|
| 1–3 | IME marked-text stranding: styling that runs beginEditing/setAttributes/invalidateLayout while hasMarkedText() strands the composition; didChangeText then bails forever and every edit drifts |
every storage-touching styling path guards !hasMarkedText() — including async ones scheduled before composition began (+SelectionTracking caret-move restyle). becomeFirstResponder resyncs as catch-all |
| 4 | Drag-move bypass: a drag-move whose drop has no valid target deletes via shouldChangeText→replaceCharacters with no didChangeText; rawSource/blocks freeze; autosave writes stale text |
scheduleBypassedEditSyncCheck (+EditFlow.swift): a next-run-loop check finds an unconsumed storage pendingEdit and runs the sync (breadcrumb above) |
| 5 | Heal leaped the caret: the round-4 heal ran against a stale selection and moved the caret | heal collapses/derives the caret from the pendingEdit hull before syncing |
| 6 | Queued selection fixup: TK2's _fixSelectionAfterChangeInCharacterRange stays queued after a bypass and fires at the next endEditing (even an attribute-only restyle), remapping the stale selection and leaping even a freshly set, valid caret |
heal sets the caret from the pendingEdit hull before the sync AND re-asserts it after (+EditFlow.swift) |
| 7 | Same queued fixup on the NORMAL edit path (not the heal): armed by a cross-block caret move (schedules the async caret-move restyle), the fixup fires during syncRawSourceFromDisplay→recomposeDirty's endEditing on an ordinary keystroke and leaps the caret to the block boundary; the normal path styles settingSelection=false and never re-asserts, so it persists |
syncRawSourceFromDisplay captures the pendingEdit-hull caret before consumePendingEdit, re-asserts it after recomposeDirty if the fixup moved it (+EditFlow.swift; breadcrumb re-asserting caret after fixup leap (normal path)) — fix not yet confirmed by a deterministic repro, live-verifiable via the breadcrumb |
Confirm the guards exist:
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
FENCED WRONG PATHS (each proven dead — do not retry)
- Fixing by nudging the caret at symptom time. The symptom is armed seconds-to-minutes earlier (round 6: drift at 22:13 armed at 22:11:57). You'd patch the wrong instant.
- Trusting a headless regression test. The round-6 test passes with and without the fix. Headless runs the fixup synchronously.
- Shipping on reasoning alone. Rounds 1–5 all came back. Round 6's first fix candidate "worked by reasoning" and failed in the repro within a minute.
- Assuming
didChangeTextpairs with every mutation. AppKit violates this (round 4). - Mutating storage mid-composition. That is the original bug (rounds 1–3).
- Expecting a scripted keystroke replay to arm round 7. Round 7 was replayed
faithfully from a reconstructed
t0(every keystroke + capped pauses through the whole drift window) and it did not produce the block-end leap. Programmatic caret moves (clickoff) and imprecise synthetic clicks (realclickoff— lands off-by-a-few, diverges, crashes) do not fully arm it. The arming needs real mouse clicks at real positions and probably the real session's two-document window switching (becomeFirstResponderresync is the prime suspect). Round-8 lead: two open docs + real clicks, or instrumentbecomeFirstResponder/the caret-move restyle to catch the next live occurrence. Compressed replays also coalesce a bypass with the next keystroke into onependingEdithull (never happens live — the heal runs first), producing off-by-one breadcrumb noise; don't trust it as a repro.
PHASE 2 — Capture evidence
- Launch with verbose diagnostics (debug bundle; file arg is
argv[1]):build/EdmundDbg.app/Contents/MacOS/edmd DOC.md \ -settings.general.diagnosticLogging YES \ -settings.advanced.verboseEditorDiagnostics YES \ -ApplePersistenceIgnoreState YES - Decode the trace fields:
sel/active/marked/up/undo/blocks/storLen/rawLen.up=Y= event arrived mid-recompose (suspicious). Healthy ordering:shouldChangeText→selectionDidChange (up=N)→synced; a transient⚠︎LEN-MISMATCHbetween those is normal, a persisting one is not. - Find the FIRST bad line, then walk BACKWARDS. The visible symptom is often the second half of a two-part mechanism.
traceSelectionOriginlogs the call stack of whoever moved the selection mid-recompose — this is what named_fixSelectionAfterChangein round 6. If the caret moves and you don't know who moved it, this answers it in one run.- Reconstruct the document. Wrapped-paragraph geometry and block kinds
matter — repro against a lookalike, never
"hello world".
Gate: you have a candidate trigger hypothesis + the first-bad-line timestamp.
Otherwise instrument (add a Log.shouldTrace breadcrumb — one call stack or
state dump beats ten speculative fixes) and wait for the next occurrence.
PHASE 3 — Build the deterministic repro
Use the in-process ReproScript driver (Sources/edmd/App/ReproScript.swift,
DEBUG only; full guide in edmund-live-repro-and-diagnostics). Commands:
sleep / caret / type / backspace / bypassdelete / assertcaret / logsel.
Worked example — the round-6 minimal repro (the first deterministic repro in six rounds):
sleep 2000
bypassdelete Sizemore,
sleep 800
logsel # broken build: 321 fixed build: 290
backspace 2
logsel
The deciding output was logsel 321 → 290 with the fix, every run, window
not even visible.
Rules that make repros survive editing:
- Needles, not offsets — offsets go stale the instant the script edits.
- Real events, not method shortcuts —
insertText("")skipsdeleteBackward's selection machinery, exactly where round 6 lived. - Replicate AppKit-internal call sequences verbatim —
bypassdeletedoesshouldChangeText→replaceCharacters, nodidChangeText, not an approximation. For a new internal path, pin its real sequence from atraceSelectionOriginstack first, then replay it (extend ReproScript ~10 lines).
Gate: a frozen script (exact commands + document) whose logsel /
assertcaret PASS/FAIL output discriminates the broken build from the
current one.
No repro after honest attempts? The hypothesis is wrong, or fidelity is too low — a mouse-only path (real drag-select/drag-move) needs the CGEvent driver (edmund-live-repro-and-diagnostics §4). Loop back to Phase 2.
PHASE 4 — Solution menu (ranked; each with a proof obligation)
Pick by the observation pattern. Cheaper is higher.
- Add a missing guard on an existing invariant (cheapest, most likely for a
new round). Guard inventory:
!hasMarkedText()on the offending styling path; bypass-check scheduling on a new mutation entry point; caret re-assertion after a restyle. Proof: the frozen repro flips to PASS and no other.reproregresses. Selects when: a specific new code path shows the round-1/4/6 signature that its siblings already guard. - Extend the heal to a new bypass source. Proof: first add a ReproScript
command that replays the new source's exact call sequence, show it
reproduces the freeze, then show the extended heal fixes it. Selects when:
trace shows a
shouldChangeTextwith no close-out from a path other than drag-move. - Intercept/reorder the queued fixup. Proof obligation: explain when
TK2 queues and fires
_fixSelectionAfterChangeand show the reorder does not fight AppKit's machinery (no oscillation, no double-move). Selects when:traceSelectionOriginnames the fixup and the caret is valid before it fires. Higher risk — you are stepping into private AppKit ordering. - Structural: make rawSource sync independent of
didChangeTextpairing (e.g. a storage-version counter that reconciles regardless of which callbacks fired). Biggest change; candidate, unproven — route through edmund-research-frontier (frontier item "caret integrity by construction"). Proof: all historical.reproscripts + a randomized bypass-fuzzer soak stay green with the callback-pairing assumption removed.
PHASE 5 — Validate and promote
- Fix candidate flips the frozen repro to PASS within the same run. (If it only "works by reasoning," it is not done — round history.)
- Soak (edmund-live-repro-and-diagnostics §3): one script, one run, 4–5
trigger cycles at different document positions with ordinary editing between,
assertcaretafter every predictable step. Green across all cycles and byte-identical finalrawLenacross repeated runs = deterministic. swift testgreen (also the Stop hook).- Add a regression test even if it can't discriminate the live mechanism —
it locks the headless contract (assert
rawSource == string; see theBypassedEditSyncTests/MarkedTextDesyncTestsfamily). Keep the.reproscript in the repo for the live half. - Update
docs/investigations/delete-drift-investigation.mdwith the new round (symptom → root cause → repro recipe → fix → status), anddocs/ARCHITECTURE.md§8 if an invariant changed — same PR. - Route through edmund-change-control: branch
fix/…offmain, small commits, never auto-push/PR/merge. (No screencapture unless the fix also draws.)
Never promote a fix that only "works by reasoning."
When NOT to use this skill
- Viewport/scroll drift where the caret itself does not leap →
edmund-debugging-playbook +
docs/investigations/viewport-glitch-investigation.md. - Just need the repro driver mechanics / trace decoding → edmund-live-repro-and-diagnostics.
- The AppKit theory behind the mechanisms → textkit2-appkit-reference.
- The historical record of prior rounds → edmund-failure-archaeology.
- The structural (round-∞) redesign as a research project → edmund-research-frontier + edmund-research-methodology.
Provenance and maintenance
Verified 2026-07-05 against docs/investigations/delete-drift-investigation.md,
Sources/edmd/App/ReproScript.swift, and
Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift.
grep -nE '^## Round' docs/investigations/delete-drift-investigation.md # round chronicle
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -oiE '"(bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
# round-6 discriminating numbers (321 broken / 290 fixed):
grep -n '321\|290' docs/investigations/delete-drift-investigation.md
When round 7 lands, add its row to Phase 1 and its dead ends to the fenced list.
.agents/skills/edmund-change-control/SKILL.md
npx skills add I7T5/Edmund --skill edmund-change-control -g -y
SKILL.md
Frontmatter
{
"name": "edmund-change-control",
"description": "How changes are classified, gated, and reviewed in the Edmund repo. Load at the START of any task that will modify the repo; before committing, branching, or proposing a merge or release; when deciding whether a change needs a test, a screencapture visual check, or a live repro; when unsure what requires explicitly asking the maintainer (push, PR, merge, release, deleting uncommitted work, touching test-files\/, adding dependencies). Contains the non-negotiable rules with the historical incident behind each one."
}
Edmund change control
Last verified: 2026-07-05, against main @ fe8a1f5 (release 0.1.3).
This is the gatekeeping doc: what class of change you are making, which gate it must pass, and the rules that are never traded away. The rules exist because each was paid for — the incident column is not decoration.
0. Before you start (task setup)
[ ] Read docs/ARCHITECTURE.md before any non-trivial change (its own rule)
[ ] git status — note any uncommitted work; never clobber it
[ ] Not on main? Fine. On main? Branch NOW, before the first edit
[ ] Classify the change (§1) so you know the gate before you write code
[ ] Edit-pipeline / selection work? Plan the live repro FIRST — if you can't
reproduce the bug, you can't prove the fix (§3, last row)
1. Classify the change, apply the gate
Classify FIRST, before writing code. The class decides the verification bar.
| Class | Examples | Gate before commit |
|---|---|---|
| Docs-only | ARCHITECTURE.md, README, docs/*.md, comments | None beyond review. swift test still runs as a Stop hook; ignore no failures it surfaces. |
| Code (logic) change | Parser, block model, helpers, non-drawing refactor | swift test green. New behavior or bug fix → add a test that fails without the change. |
| Visually-drawing change | Anything in Rendering/, overlays, decorations, padding, fonts, layout fragments |
All of the above, PLUS build the app and screencapture the result (window-by-id method — see edmund-live-repro-and-diagnostics). Headless layout is not proof for anything that draws. |
| Edit-pipeline / selection behavior | +EditFlow, +Composition, +SelectionTracking, +Undo, caret, IME, drag, viewport timing |
All of the above, PLUS a live repro or soak script (-debug.reproScript, see edmund-caret-integrity-campaign and docs/dev-guides/live-repro-guide.md). Headless tests cannot exercise deferred AppKit machinery — the queued selection fixup, drag paths, IME. Rounds 1–5 of delete-drift shipped on tests + reasoning; all recurred. |
| Release | Version bump, tag, appcast | Run misc/before-you-release.md top to bottom, then misc/how-to-release.md. See edmund-release-and-operate. Never start a release without being asked. |
Notes on the gates:
swift test(~750+ tests, ~10s) also runs automatically as a Stop hook (.Codex/settings.json:swift test 2>&1 | tail -5) at the end of every turn. That is a safety net, not the gate — run it yourself before committing so the failure is yours to see, not the hook's.- A change can be in multiple classes. Apply the union of gates. A caret fix that also moves a decoration needs test + screencapture + live repro.
- "Draws" is broad: padding, insets, colors, wrapping, fragment frames. If a human could see the diff, screenshot it.
Classification edge cases that have gone wrong before:
- "It's just an attribute change" is NOT automatically the code-logic class.
If the attributes change measured geometry (font size, paragraph spacing,
hidden-delimiter width), it draws AND it needs
invalidateLayout(for:)— see §3. - "It's just a restyle helper" that runs
beginEditing/setAttributeson storage is edit-pipeline class if it can fire during IME composition or after a bypassed edit. When in doubt, grep forhasMarkedTextguards on the sibling paths and match them. - A test-only change is docs-class for gating purposes (nothing to screenshot), but the Stop hook still must pass — a broken test is a broken commit.
- Release-adjacent edits (
Info.plistversions,CHANGELOG.md,appcast.xml,scripts/release.sh,.github/workflows/) are release class even when tiny. The v0.1.0→0.1.1 Sparkle failure came from the build script's signing step, not from app code.
2. Git discipline
From AGENTS.md + ARCHITECTURE §12 + the repo's own history (git branch -a).
- Branch off
mainfor every fix. Never commit straight tomain. One feature/fix per branch. - Branch prefixes actually in use (verified):
fix/,feat/,feature/,docs/,chore/,uiux/,ui/,ux/,markdown/,bug/,refactor/,ci/,release/. Preferfix/,feat/,docs/,chore/for new work;uiux/for visual polish;markdown/for syntax-feature work. - Small, logical commits; commit frequently. A commit that mixes the fix with a drive-by refactor is two commits done wrong.
- NEVER auto-push, open a PR, or merge. Only when the maintainer explicitly asks. No exceptions for "it's just docs."
- Never discard uncommitted changes. No
git checkout -- .,git reset --hard,git cleanon a dirty tree without explicit permission. - Commit messages follow the observed style:
fix(editor): …,docs: …,ui: …,chore: …— short imperative subject.
3. The non-negotiables
Each rule was established by an incident. Verify against the cited doc before arguing an exception.
| Rule | Rationale | Incident |
|---|---|---|
Text storage always equals rawSource; rendering is attribute-only. Never insert/delete display characters — hide delimiters, never strip them. |
Display offset == raw offset (identity mapping) is what every selection, sync, and heal path assumes. Break it and every later edit drifts. | The delete-drift saga: six rounds over months, each recurrence traced to storage/rawSource divergence in some path. docs/investigations/delete-drift-investigation.md. |
TextKit 2 only. Never touch NSTextView.layoutManager; never store NSTextBlock/NSTextTable attributes. |
Either one silently and permanently reverts the view to TextKit 1. A DEBUG tripwire asserts if TK1 engages — heed it. | ARCHITECTURE §2; the tripwire exists because the reversion is otherwise invisible. |
No NSTextAttachment. Images/icons are drawn as overlays. |
TK2 only honors attachments on U+FFFC, which rawSource never contains (see rule 1). |
ARCHITECTURE §2, §5. |
Never draw images on wrapping (multi-line) fragments; use stroked CGPaths instead. |
A TK2 image on a wrapping fragment wedges layout — collapses the fragment to one line. Shapes don't trigger it. | The callout custom-title icon: docs/investigations/archives/callout-title-wrap-investigation.md; fix in ae61644 (stroked path, not image). |
Every storage-touching styling path guards !hasMarkedText() — including async paths scheduled before composition began. |
Mutating storage mid-IME-composition strands the marked text; didChangeText then bails forever on its own guard and every later edit drifts. |
Delete-drift rounds 1–2 (IME stranding cascade). docs/investigations/delete-drift-investigation.md. |
Never assume didChangeText follows every edit. Sync paths must survive a bypassed edit. |
AppKit's drag-move-to-nowhere deletes via shouldChangeText → replaceCharacters and never calls didChangeText, silently freezing rawSource. The heal (scheduleBypassedEditSyncCheck in +EditFlow) exists for this. |
Delete-drift round 4 (9f99795); rounds 5–6 hardened the heal itself. |
Attribute-only restyles that change geometry must invalidateLayout(for:) the range. |
TK2 does not re-measure on attribute change; the fragment keeps a stale frame — empty bands, clipped lines. | ARCHITECTURE §8; recomposeDirty and the idle drain already do this — new paths must too. |
Undo restore is diff-based recomposeReplacing — never a full recompose. |
Full recompose resets every fragment to a TK2 height estimate; the follow-up scroll lands wrong and the viewport drifts. | Undo/redo viewport drift — one of the costliest failures here. Fixed in 5bb2b40 (fix(undo): select + center the changed text; diff-based snapshot restore). |
Never blanket pkill -x edmd. pgrep -x edmd first, check start times, kill only the PID you launched. |
The maintainer's daily-driver app shares the binary name. A blanket pkill kills their editor with their work in it. (ARCHITECTURE §1's pkill -x edmd shorthand predates this rule — don't copy it.) |
Established after the maintainer's own instance was killed during a debugging session. |
| Never request macOS Computer Access (Screen Recording / Accessibility). | Both are already granted to the tools you use. Requesting again re-prompts the maintainer and can wedge TCC state. | AGENTS.md "Environment"; the -debug.reproScript driver exists precisely so repros need no new TCC grants. |
| Visual judgments ("balance the padding", "is it centered") are MEASURED from screencapture pixels, not eyeballed. | Eyeballed "looks right" repeatedly shipped asymmetric spacing. Crop the window, count pixels, state the numbers. | Maintainer's explicit rule from UI-polish rounds (status-bar / table-padding branches). |
Files in test-files/ are the maintainer's manual test corpus. Never rewrite them for automation; test-files/todo.md especially is owner-edited. |
They encode the maintainer's by-hand regression walk. Automation churn destroys that. Create your own fixtures in a scratch dir or Tests/. |
Standing maintainer rule. |
| Never ship a fix for a live-input-layer bug (caret, IME, drag, selection timing) on reasoning alone. A frozen repro script must falsify the bug before and confirm the fix after. | This bug class does not reproduce headless (the test harness runs TK2's queued fixup synchronously). Reasoning about deferred AppKit machinery has a ~0% shipping record here. | Delete-drift rounds 1–5 each shipped a plausible fix; each came back. Round 6 finally held because the ReproScript driver reproduced the drift deterministically first. docs/dev-guides/live-repro-guide.md. |
The two incidents that shaped this table
Worth knowing as stories, because the rules read as pedantry until you see the cost:
- Delete-drift (six rounds). The hardest live problem this repo has had.
A caret that drifted after deletes. Round 1 blamed IME stranding — plausible
fix, shipped, recurred. Round 2 disabled remaining marked-text sources —
recurred. Round 3 stopped guessing and built diagnostics (selection tracing,
event logs). Round 4's diagnostics caught a drag-move deleting storage with
no
didChangeText— the heal was born. Round 5: the heal itself leaped the caret via a stale selection. Round 6 found the actual drift mechanism — TextKit 2's queued_fixSelectionAfterChangefiring at the nextendEditing— and held only because the ReproScript driver could replay the exact keystroke sequence deterministically. Five shipped fixes failed; the one preceded by a frozen repro stuck. That asymmetry IS the change-control policy for this bug class. - Undo/redo viewport drift. Undo restored a snapshot via full
recompose, which reset every fragment to a TK2 height estimate; the follow-up scroll-to-caret then landed wrong and the viewport jumped. The fix (5bb2b40) diffs the snapshot against current text and applies only the changed span withrecomposeReplacing. Moral: in TK2, layout state is part of the document state you must preserve — "re-render everything" is never the safe fallback here, it is the bug.
4. Review expectations
- ARCHITECTURE.md is updated in the same PR whenever you learn something non-obvious or change an invariant. The doc's own header demands this; the gotchas in §8 all arrived this way.
- Quirks are documented as comments at the code site — the edge case, the workaround, the why. Not in commit messages, not in AGENTS.md.
- New known issues go in ARCHITECTURE §9 with a one-line repro and a
pointer to any deeper write-up in
docs/. - Big investigations (multi-round bugs) get a
docs/*-investigation.mdchronicle — seeedmund-failure-archaeologyfor the pattern.
5. Pre-commit checklist (copy-paste)
The workflow that worked (ARCHITECTURE §12 + AGENTS.md). Run it verbatim:
[ ] swift test — all green (also enforced by the Stop hook; don't rely on it)
[ ] New behavior / bug fix → a test exists that fails without the change
[ ] Draws anything? → build app, screencapture window-by-id, look at the PNG
[ ] Edit-pipeline / selection change? → live repro or soak script passed
[ ] On a branch off main (fix/…, feat/…, docs/…, chore/…), NOT on main
[ ] Diff touches only what the task needs; style matches surroundings
[ ] Learned something non-obvious? → ARCHITECTURE.md updated in this change
[ ] Quirk introduced/found? → comment at the code site
[ ] Commit is small and logical; message matches repo style (fix(scope): …)
[ ] NOT pushing, NOT opening a PR, NOT merging (unless explicitly asked)
6. Ask the maintainer first — always
Never do these unprompted; ask and wait for an explicit yes:
| Action | Why it's gated |
|---|---|
git push, opening a PR, merging anything |
Standing rule in AGENTS.md ("Never auto-push, PR, or merge"). The maintainer reviews and merges. |
| Starting or tagging a release | A tag push fires CI, builds, signs, publishes a GitHub Release, and updates the appcast that live users poll. Not reversible quietly. |
| Deleting anything uncommitted (files, stashes, working-tree changes) | "Never delete uncommitted changes" — the maintainer's in-progress work may be in the tree. |
Editing anything in test-files/ |
Manual test corpus; todo.md there is owner-edited. Make fixtures elsewhere. |
| Adding a dependency | Current set is deliberately three (swift-markdown, SwiftMath, Sparkle); each new one is a codesign/bundle/update-pipeline liability (see the SwiftMath bundle saga, ARCHITECTURE §8). |
Changing .Codex/settings.json hooks or permissions |
Alters what runs automatically on the maintainer's machine. |
When NOT to use this skill
| You need… | Go to |
|---|---|
| The invariants' full technical statement and render pipeline | edmund-architecture-contract |
| To debug a failure, read traces/logs | edmund-debugging-playbook |
| The history of a past incident in depth | edmund-failure-archaeology |
| TextKit 2 / AppKit API behavior details | textkit2-appkit-reference |
| Launch flags, debug bundle, settings | edmund-config-and-flags |
| Build issues, stale binaries, environment | edmund-build-and-env |
| Executing a release / operating the app | edmund-release-and-operate |
| The screencapture / ReproScript mechanics | edmund-live-repro-and-diagnostics |
| Test-writing patterns and QA strategy | edmund-validation-and-qa |
| Writing docs / chronicles | edmund-docs-and-writing |
| Caret/selection bug-class specifics | edmund-caret-integrity-campaign |
Provenance and maintenance
- Sources:
AGENTS.md(repo root),docs/ARCHITECTURE.md§1 §2 §8 §9 §12,.Codex/settings.json(Stop hook),misc/before-you-release.md,misc/how-to-release.md,docs/investigations/delete-drift-investigation.md(rounds 1–6),docs/investigations/archives/callout-title-wrap-investigation.md,git log/git branch -aas of fe8a1f5 (2026-07-05). - Commits cited were verified in
git log: 5bb2b40 (diff-based undo restore), 9f99795 (round-4 heal), ae61644 (stroked-path callout icon), 1b1420a (round-6 caret re-assert). - Two rules rest on maintainer statements rather than repo docs: the
measure-from-pixels rule and the
test-files/ownership rule. If either gets written into ARCHITECTURE.md, point at it here. - Maintain: when a new incident produces a new rule, add a row to §3 with the
incident pointer in the same PR that adds the rule to ARCHITECTURE.md. When
the Stop hook in
.Codex/settings.jsonchanges, update §1's note.
.agents/skills/edmund-config-and-flags/SKILL.md
npx skills add I7T5/Edmund --skill edmund-config-and-flags -g -y
SKILL.md
Frontmatter
{
"name": "edmund-config-and-flags",
"description": "Catalog of every configuration axis in the Edmund Markdown editor — user settings (UserDefaults keys, defaults, where consumed), launch arguments (diagnostic + repro flags), compile-time gates, and logging config. Load when adding or changing a setting, hunting which flag controls a behavior, launching the app with debug flags, auditing defaults, or wiring a new preference into the live editor. This skill drifts fastest of the set — every table ends with a re-verification grep. Not for the invariants (see edmund-architecture-contract), release flags (edmund-release-and-operate), or how to READ the logs (edmund-live-repro-and-diagnostics)."
}
Edmund configuration & flags
Ground-truth catalog of every knob. Code wins over docs — every value below was read from source on 2026-07-05; re-verify with the greps at the end before trusting a value in a decision.
Two source-of-truth files:
Sources/edmd/Settings/AppSettings.swift— every UserDefaults key + typed accessor.Sources/edmd/Settings/*View.swift(Appearance / General / Advanced) +FontSettings— the SwiftUI panes (@AppStorage).
Definitions used below: UserDefaults = macOS per-app persisted key/value store; argument domain = passing -<key> <value> on the command line overrides that default for one launch; @AppStorage = SwiftUI wrapper binding a view to a UserDefaults key.
1. User settings (UserDefaults keys)
Every key is a static let in AppSettings.swift. The key string (not the
Swift name) is what you pass as a launch arg.
| Swift name | Key string | Purpose |
|---|---|---|
reopenWindows |
settings.general.reopenWindows |
Reopen last windows on launch |
startupAction |
settings.general.startupAction |
What to do at startup (new doc / reopen / nothing) |
autoSaveWithVersions |
settings.general.autoSaveWithVersions |
NSDocument autosave-in-place vs versions |
conflictResolution |
settings.general.conflictResolution |
File-changed-on-disk handling |
suppressInconsistentLineEndingWarning |
settings.general.suppressInconsistentLineEndingWarning |
Silence mixed-line-ending warning |
diagnosticLogging |
settings.general.diagnosticLogging |
On/off for file logging (opt-out; see §4) |
logRetention |
settings.general.logRetention |
Days of logs to keep (pruned on configure) |
appearanceMode |
settings.appearance.mode |
Light / dark / system |
maxContentWidthCm |
settings.appearance.maxContentWidthCm |
Max column width, stored in CENTIMETRES (see note) |
contentWidthUnit |
settings.appearance.contentWidthUnit |
Display unit only (cm/in); the stored value is always cm |
renderBlankLinesAsBreaks |
settings.reading.renderBlankLinesAsBreaks |
Read-mode blank-line handling |
sourceMode |
settings.view.sourceMode |
When on, Source replaces Edit in the ⌘E toggle; honored on open |
verboseEditorDiagnostics |
settings.advanced.verboseEditorDiagnostics |
Verbose editor trace (see §4; pairs with diagnosticLogging) |
sendCrashLogs |
settings.advanced.sendCrashLogs |
Opt-in crash upload — currently INERT (see note) |
sentCrashReports |
settings.advanced.sentCrashReports |
Dedup set of already-uploaded .ips filenames |
lastWindowHeight |
settings.window.lastHeight |
Persisted window sizing (see the frame-not-content trap) |
automaticallyChecksForUpdates |
SUAutomaticallyChecksForUpdates |
Sparkle's own key (not namespaced) |
Content width (the physical-column design): persisted as centimetres
(maxContentWidthCm); contentWidthUnit is a display unit only. The column is
an absolute physical cap converted to points via the display's real PPI
(NSScreen.physicalPPI, from CGDisplayScreenSize), applied as a symmetric
textContainerInset.width. Default is locale-aware — 5 in (US) / 12 cm
(elsewhere) — and doubles as the slider's magnetic snap point. Recomputed on
resize and on NSWindow.didChangeScreenNotification. Consumer path lives in
EditorTextView+ContentWidth.swift.
Window sizing trap: persistence must round-trip the frame size, not the
contentView.bounds size — reapplying content size grows the window by the
title-bar + toolbar height on every reopen, and heights below minSize get
silently rejected. Save window.frame.size, reapply with window.setFrame(_:)
after the toolbar is installed. (Note: the key on disk is
settings.window.lastHeight — code, not the lastWindowSize some docs say.)
Crash uploading is inert: sendCrashLogs defaults off AND the Settings ▸
Advanced toggle is commented out in AdvancedSettingsView.swift, and
CrashReporter.reportingEndpoint is a REPLACE-ME.invalid placeholder
(CrashReporter.swift:27, // TODO: real server). Nothing uploads today.
Un-inert it only when a receiving server exists (see edmund-release-and-operate).
2. Launch arguments
macOS reads -<UserDefaults-key> <value> into the argument domain. Pass the
key string from §1, not the Swift name. The file to open must be
argv[1] (before the flags).
build/EdmundDbg.app/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES \
-debug.reproScript SCRIPT.repro \
-ApplePersistenceIgnoreState YES
| Flag | Effect |
|---|---|
-settings.general.diagnosticLogging YES |
Turn on file logging for this run |
-settings.advanced.verboseEditorDiagnostics YES |
Emit the verbose editor trace (sel/active/marked/up/…) |
-debug.reproScript <path> |
DEBUG builds only — replay a keystroke script (ReproScript.swift) |
-ApplePersistenceIgnoreState YES |
Apple's flag — stop state restoration reopening mutated docs |
-debug.reproScript is the only Edmund-specific debug key; it does not have
an AppSettings accessor — it is read directly in ReproScript.swift. It is
compiled out of release builds.
3. Compile-time axes
#if DEBUG gates live in: EditorTextView.swift, EditorTextView+EditFlow.swift,
Diagnostics/Log.swift, edmd/App/main.swift, edmd/App/ReproScript.swift.
What they gate:
- The TextKit-1 tripwire (
EditorTextView.swift:273+): a DEBUG observer onNSTextView.willSwitchToNSLayoutManagerNotificationthat asserts if the view ever falls back to TextKit 1. Ships only in DEBUG; the fallback itself is silent and permanent (see edmund-architecture-contract). - ReproScript — the whole in-process keystroke driver.
- Log level threshold —
Log.swiftcompiles a lower floor in DEBUG (debug+) than release (info+); see §4.
Named tuning constants (not user-facing, but they behave like knobs):
| Constant | Value | Where | Meaning |
|---|---|---|---|
fullLayoutMaxLength |
100_000 |
EditorTextView.swift:80 |
Docs ≤ this many UTF-16 units are kept fully laid out (below the TK2 estimate regime). Consumed at +LazyStyling.swift:133. |
4. Logging config
Read Sources/EdmundCore/Diagnostics/Log.swift and
EditorTextView+Diagnostics.swift.
- API:
Log.{debug,info,error}(_:category:),Log.measure(_:) { … }. - File:
~/.edmund/logs/edmund-YYYY-MM-DD.log, written on a private serial queue. - Config flow:
AppSettings.applyLogging()pushes the toggle + retention intoLog.configureat launch and on change; retention pruning happens there. - Two independent switches:
diagnosticLogging(writes anything at all) andverboseEditorDiagnostics(adds the per-event editor trace). For a live repro you almost always want both on. Verbose lines are gated behindLog.shouldTrace. - The log is opt-out (on by default), retention-pruned; the user only toggles it and picks a retention window in Settings ▸ General ▸ Diagnostics.
Trace-field decoding (sel/active/marked/up/undo/blocks/storLen/rawLen,
⚠︎LEN-MISMATCH, traceSelectionOrigin) is covered in
edmund-live-repro-and-diagnostics and edmund-debugging-playbook — one
home per fact; this skill only says which flags turn the trace on.
5. ReproScript command surface
Sources/edmd/App/ReproScript.swift, DEBUG only. One command per line, #
comments allowed.
| Command | Effect |
|---|---|
sleep <ms> |
wait before next command |
caret <needle> |
place caret before first occurrence of <needle> |
type <text> |
one real key event per char (~80 ms apart) |
backspace <n> |
n real delete keystrokes (~300 ms apart) |
bypassdelete <needle> |
simulate drag-move source deletion: shouldChangeText + storage mutation, no didChangeText |
assertcaret <needle> |
log PASS/FAIL iff caret sits exactly before <needle> |
logsel |
log selection, rawSource length, doc count |
Adding a command is ~10 lines in ReproScript.swift. Usage, soak patterns, and
launch recipe: edmund-live-repro-and-diagnostics.
6. How to ADD a setting (checklist)
Worked from an existing real path (sourceMode / content width). To add a
user-facing setting:
- Add a
static letkey + typed accessor inAppSettings.swift(namespace the key string:settings.<area>.<name>). - Bind it in the relevant SwiftUI pane with
@AppStorage(AppSettings.<key>). - If it must affect open documents live, add/extend an
applyTo…broadcast (see the font/line-height/content-widthapplyTo…helpers) so every openDocument.editorpicks it up — a setting that only takes effect on next open is usually a bug. - Pick a sane default (register it, or make the accessor default when absent).
- New behavior needs a test + (if it draws) a screencapture check — route through edmund-change-control and edmund-validation-and-qa.
When NOT to use this skill
- Understanding why an invariant exists → edmund-architecture-contract.
- Release/signing/appcast flags,
RELEASE_TOKEN, Sparkle keys → edmund-release-and-operate. - Interpreting log output / running a repro → edmund-live-repro-and-diagnostics.
- Which change needs which gate → edmund-change-control.
- Build-time flags in the toolchain sense (stale builds,
swift package clean) → edmund-build-and-env.
Provenance and maintenance
Verified 2026-07-05 against source. This skill drifts fastest — re-verify each table before relying on it:
# §1 all keys + strings:
grep -nE 'static let [a-zA-Z]+ = "[a-zA-Z0-9._]+"' Sources/edmd/Settings/AppSettings.swift
# §1 crash toggle still commented out / endpoint still placeholder:
grep -n 'Crash reports:' Sources/edmd/Settings/AdvancedSettingsView.swift
grep -n 'reportingEndpoint' Sources/EdmundCore/Diagnostics/CrashReporter.swift
# §2 repro flag key:
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
# §3 tripwire + constant:
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
# §5 repro commands:
grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
Known doc-vs-code discrepancies (code wins): ARCHITECTURE §7 calls the
window-size key lastWindowSize; the code key is settings.window.lastHeight
(lastWindowHeight). If you touch window persistence, trust the code.
.agents/skills/edmund-debugging-playbook/SKILL.md
npx skills add I7T5/Edmund --skill edmund-debugging-playbook -g -y
SKILL.md
Frontmatter
{
"name": "edmund-debugging-playbook",
"description": "Load this FIRST when any bug report or unexpected behavior arrives in Edmund: caret lands in the wrong place after delete\/typing, viewport jumps or scroll-to-target misses, rendering is wrong or missing, empty bands or clipped lines, everything suddenly renders as plain text, app crashes or won't launch, a code change \"doesn't take\" after rebuild, undo\/redo lands the viewport wrong, IME\/CJK\/accent input misbehaves, right-click shows the wrong menu, window grows on reopen, or a Sparkle update fails. Symptom-to-mechanism triage table, the traps that cost real debugging time, discriminating trace checks, the repro escalation ladder, and the open-bug inventory so known bugs are not rediscovered fresh."
}
Edmund debugging playbook
Date-stamped 2026-07-05. Runbook for triaging any Edmund bug. Start at the triage table, run the discriminating first check before forming a theory, and check the open-bug inventory before declaring a discovery.
When NOT to use
- Making a change, not chasing a bug →
edmund-change-control. - You already know the bug is live-only and need to build a deterministic
repro →
edmund-live-repro-and-diagnostics(full ladder detail; summary in §5 here). - Caret-drift class specifically, with its six-round history →
edmund-caret-integrity-campaign. - Build/toolchain/stale-binary mechanics beyond the quick checks here →
edmund-build-and-env. - Release, signing, appcast, Sparkle pipeline →
edmund-release-and-operate. - TextKit 2 / AppKit API semantics reference →
textkit2-appkit-reference. - Launch flags and settings keys reference →
edmund-config-and-flags. - How past investigations were run and why →
edmund-failure-archaeology,edmund-research-methodology. - Pre-merge verification of a fix →
edmund-validation-and-qa.
1. First 15 minutes — any new bug
Run this checklist before hypothesizing. Every step is cheap; skipping them is how rounds 1–5 of delete-drift shipped fixes that came back.
- Check the open-bug inventory (§6). If the symptom matches a known open bug, you are done triaging — link the backlog entry and its repro asset.
- Get the logs.
ls -t ~/.edmund/logs/and read the day's file (edmund-YYYY-MM-DD.log). Grep for the three permanent breadcrumbs:
Also grep forgrep -n "healing storage edit that bypassed didChangeText\|repairing content above origin\|recovered stranded desync on focus regain" ~/.edmund/logs/edmund-*.loginvariant:(the always-on storage==rawSource tripwire) and⚠︎LEN-MISMATCH. - Find the first bad line and walk BACKWARDS. The user-visible symptom is often the second half of a two-part mechanism — the round-6 caret drift was armed by a silent bypass 80 seconds and dozens of healthy edits before the leap. Never start reading at the symptom timestamp.
- Rule out a stale build if this follows a rebuild: grep
strings .build/arm64-apple-macosx/debug/edmdfor a long string literal unique to the new code (≤15-byte literals are inlined on arm64 and never appear);shasumthe binary. See §3c. - Check git history for prior art.
git log --oneline -- <suspect file>plus the investigation docs indocs/. The viewport-glitch investigation found four earlier fixes all working around the same unnamed root cause. - Reconstruct the document. Get the user's file or rebuild it from the trace's block counts/lengths. Wrapped-paragraph geometry and block kinds matter; do not repro against "hello world".
- Before touching any running app:
pgrep -lx edmdthenps -o lstart=,command= -p <pid>. The user's daily-driver app shares the binary name. Never blanketpkill -x edmd— kill only the PID you launched. - Row found in §2 → run its discriminating check. No row → escalate per §5, and add the new row here when it's understood.
2. The triage table
| Symptom | Likely mechanism | Discriminating first check | Where next |
|---|---|---|---|
| Caret lands blocks away after delete or typing; text itself correct | Delete-drift class: a storage edit bypassed didChangeText (drag-move to no valid target), or TextKit 2's queued _fixSelectionAfterChangeInCharacterRange fired at a later endEditing |
grep "healing storage edit that bypassed didChangeText" ~/.edmund/logs/edmund-*.log and read the trace around it; look for selectionDidChange with up=Y at a surprising position |
edmund-caret-integrity-campaign; docs/investigations/delete-drift-investigation.md |
| Every delete drifts, persistently, until an app switch fixes it | Stranded IME composition: hasMarkedText() stuck true, didChangeText bails forever, model frozen |
Grep logs for recovered stranded desync on focus regain; check storage.string == rawSource |
docs/investigations/delete-drift-investigation.md rounds 1–2 |
| Scroller jumps; scroll-to-target misses; content shifts on scroll | TextKit 2 height estimates — off-screen frames are guesses corrected as layout reaches them | Doc length vs fullLayoutMaxLength (100k UTF-16, EditorTextView.swift) — ≤100k should be fully laid out by the settle; >100k is estimate territory |
docs/investigations/viewport-glitch-investigation.md |
| First line unreachable above the top; scroller already at 0 | TK2 strands fragments at negative y after a top-of-document edit | grep "repairing content above origin" ~/.edmund/logs/edmund-*.log — present means the repair fired (diagnosis confirmed, repair maybe raced); absent means a different cause |
docs/investigations/viewport-glitch-investigation.md Bug 2 (repair unconfirmed live) |
| Undo/redo lands viewport in the wrong place; changed text not selected | Regression of the diff-based restore contract (5bb2b40): a full recompose resets every fragment to an estimate, then the scroll measures the estimates |
Confirm restoreSnapshot still routes through range-bounded recomposeReplacing, never full recompose (+Undo.swift); check the changed range, not the stored caret, drives the viewport |
docs/investigations/viewport-glitch-investigation.md Bug 1 |
| Code/visual change "doesn't take" after rebuild | STALE BUILD — SwiftPM printed Build complete! without relinking edmd |
strings .build/arm64-apple-macosx/debug/edmd | grep "<long new literal>"; shasum before/after |
edmund-build-and-env; §3c |
| App crashes the instant any LaTeX renders | SwiftMath *.bundle missing from the .app root (its Bundle.module is hardcoded to Bundle.main.bundleURL) |
ls build/Edmund.app/*.bundle |
scripts/build-app.sh copy step; ARCHITECTURE §8 |
| Everything suddenly renders as plain text; all styling gone | Silent, permanent TextKit 1 reversion: an NSLayoutManager API was touched or an NSTextBlock/NSTextTable attribute entered storage |
DEBUG builds assert via the tripwire (textKit1FallbackTripwire, willSwitchToNSLayoutManagerNotification); audit recent diffs for layoutManager / NSTextBlock |
ARCHITECTURE §2; textkit2-appkit-reference |
| Empty bands or clipped lines after a restyle | Attribute-only change without invalidateLayout(for:) — TK2 keeps the stale fragment frame |
Is the misbehaving path a new styling path? recomposeDirty and the idle drain already invalidate; new paths must too |
ARCHITECTURE §8 |
| Weird behavior only while composing CJK / accents / emoji | A storage-touching styling path missing the !hasMarkedText() guard (including async work scheduled before composition began) |
Audit the new/changed path for the guard; check logs for a persisting LEN-MISMATCH during composition | ARCHITECTURE §8; docs/investigations/delete-drift-investigation.md |
Callout at end of file shows an extra colored line not prefixed by > |
KNOWN OPEN BUG — lives in the LIVE incremental restyle path only, not static rendering (a fresh full render is clean) | Confirm against misc/bug-repros/callout-extra-line-rendered-at-bottom.mov |
misc/backlog.md |
| Image leaves a large blank space below it | KNOWN OPEN BUG | misc/bug-repros/image-blank-after.mov |
misc/backlog.md |
| Footnotes don't render (edit or read mode) | KNOWN OPEN BUG | — | misc/backlog.md |
| Right-click on the toolbar view-mode button shows "Customize Toolbar…" | NSToolbar with allowsUserCustomization claims every secondary click over the toolbar, beating view-level handlers |
Verify the DocumentWindow.sendEvent(_:) intercept is intact (it swallows the click inside the button's bounds); note it does not cover true fullscreen |
ARCHITECTURE §8 |
| Window grows by title-bar height on every reopen | Frame-vs-content-size persistence trap: saving contentView.bounds.size and re-applying as contentRect |
Confirm lastWindowSize round-trips window.frame.size via setFrame after the toolbar is installed (Document.swift) |
ARCHITECTURE §8 |
| Sparkle update fails: "The update is improperly signed and could not be validated" | Bundle not sealed: signing only the main binary leaves no _CodeSignature/CodeResources; or the EdDSA keypair diverged from SUPublicEDKey |
Does build-app.sh still seal the whole .app before copying the SwiftMath bundle in? |
edmund-release-and-operate; ARCHITECTURE §8/§13 |
| Callout/overlay icon wedges a wrapping line down to one line | TK2 image-on-multiline-fragment wedge: drawing an image on a wrapping fragment collapses its layout; shapes do not | Is the overlay an NSImage on a fragment that can wrap? Convert to a stroked CGPath (the custom-title callout icon fix) |
docs/investigations/archives/callout-title-wrap-investigation.md |
| Dragging produces no visible selection at all | Not a bug: a selection (possibly whole-document) was already active, and dragging inside an existing selection is AppKit's drag-move gesture | Trace: was there a selectionDidChange with a large sel before the drag began? |
docs/investigations/viewport-glitch-investigation.md Bug 3 phase 1 |
| Viewport oscillates up/down during a steady drag-select | Two scroll policies fighting: drag autoscroll vs a reveal that follows the wrong end of a taller-than-viewport selection | Confirm the scrollRangeToVisible override still reveals the selection's nearest end (+TypewriterScroll.swift, commit 340fcbc) |
docs/investigations/viewport-glitch-investigation.md Bug 3 phase 2 |
| Edits do nothing at all (not drift — dropped) | isUpdating stuck true would make shouldChangeText return false |
Trace shows shouldChangeText never returning OK; distinct from the drift signature |
+EditFlow.swift |
Launching via open shows old behavior |
LaunchServices foregrounded a running instance, or ran a stale cached/translocated copy | pgrep -lx edmd first; launch by direct exec of the bundle binary |
§3e; edmund-build-and-env |
3. The traps that cost real time
Each of these burned hours to days. Read before shipping any fix.
(a) Shipping caret fixes on reasoning alone. Delete-drift rounds 1–5 each
shipped a plausible, well-argued fix — and each came back. Only round 6, the
first with a frozen deterministic live repro (bypassdelete script), named
the actual mechanism (_fixSelectionAfterChangeInCharacterRange queued by a
bypassed edit, firing at the next endEditing) — and its first fix attempt
failed in the repro within a minute, which reasoning would never have
caught. Lesson: time spent making the failure cheap to observe beats time
spent reasoning about the fix. Freeze the repro before writing the fix.
(b) Undo/redo viewport drift. Two defects hid behind one symptom:
restoreSnapshot ran a full recompose (discarding all TK2 layout, so the
follow-up scroll measured freshly manufactured estimates) and performUndo
recorded the redo snapshot with the caret at undo-invocation time, not at
the edit. Lesson: a wrong-scroll symptom can be a geometry bug and a plain
stale-state bug stacked; fix and verify them separately. The contract since
5bb2b40: diff the snapshot, apply via recomposeReplacing, select the
changed text, let the changed range drive the viewport.
(c) Stale binaries produce false conclusions. In round 6, swift build
twice printed Build complete! while linking a stale edmd — the compile
ran, the relink silently didn't — and two "failed" fix iterations were
phantoms. Detect: grep strings on the binary for a long literal unique to
the new code. Cure: swift package clean (or rm -rf .build for release
weirdness). Never hand-delete .build/…/edmd.build/ — that corrupts the
output-file-map and wedges the target until a full clean.
(d) Headless-green ≠ fixed for input-layer bugs. The round-6 unit test reconstructs the exact document and gesture and passes with and without the fix: the test harness runs AppKit's deferred selection fixup synchronously, so the broken state never forms. A green test proves nothing about the live NSTextView / TextKit 2 / input-context class. The scripted live repro is the regression harness; the unit test is only a contract spec.
(e) open -a runs stale cached copies. LaunchServices can foreground an
already-running instance instead of relaunching, and can execute a stale
cached/translocated copy of the bundle — you debug last hour's code. Always
launch by direct exec: build/Edmund.app/Contents/MacOS/edmd file.md &
(after the §1 step-7 pgrep check).
(f) Trusting off-screen fragment y-coordinates. A TK2 fragment's frame is
real only once laid out; everything off-screen, plus total document height,
is an estimate. Any code that measures before ensuring layout of the
viewport↔target span lands wrong (this is Bug 1a, the typewriter-scroll
gotcha, and most historical viewport glitches). Ensure layout for the target
range first, then align to real geometry — and never verify a visual fix
from headless layout: measure from screencapture pixels.
4. Discriminating experiments — cheap checks that split hypothesis spaces
Verbose diagnostics launch (defaults keys are namespaced; the file must be argv[1]):
build/Edmund.app/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES
Or toggle in Settings ▸ Advanced ("Save diagnostic logs" + "Verbose editor
tracing"). Logs land in ~/.edmund/logs/edmund-YYYY-MM-DD.log.
Trace field vocabulary (source of truth:
Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift,
diagnosticState). Every trace line ends with:
| Field | Meaning |
|---|---|
sel={loc,len} |
current selection |
active= |
active block index (or nil) |
marked= |
marked-text range, - if none (non-- outside a live composition = stranded) |
up=Y/N |
isUpdating — Y means the event arrived MID-RECOMPOSE |
undo=Y/N |
isUndoRedoing |
blocks= |
block count |
storLen= / rawLen= |
storage vs rawSource lengths |
⚠︎LEN-MISMATCH |
appended when they differ |
Healthy vs suspect edit orderings:
- Healthy:
shouldChangeText→selectionDidChange(up=N) →synced. A transient⚠︎LEN-MISMATCHbetween those lines is normal (storage moves before rawSource syncs). - Suspect: a
selectionDidChangewithup=Yat a surprising position; a persisting LEN-MISMATCH; ashouldChangeTextwith nosynced/SKIPPED/DEFERREDline after it (bypasseddidChangeText); thehealing storage edit that bypassed didChangeTextbreadcrumb.
traceSelectionOrigin: under verbose tracing, any selection change
arriving mid-recompose (up=Y) or with an unconsumed pendingEdit logs a
condensed call stack naming the AppKit path that moved the caret. This is
what named _fixSelectionAfterChangeInCharacterRange in round 6. If your bug
moves the caret and you don't know who moved it, this answers it in one run.
Walk backwards from the first bad line, not forwards from the symptom. The round-6 drift was armed 80 seconds before the visible leap. Find the first line whose state is wrong, then read earlier.
verifyEditorInvariants (same file): the O(1) length check
(storage.length != rawSource.length) logs an error whenever logging is on
— no verbose toggle needed — so a hard-invariant break always leaves an
invariant: line. The full structural checks (string equality, blocks
reconstruct rawSource, block ranges in bounds) run under verbose tracing and
assert in DEBUG. An invariant: error in a user's log is a model desync,
full stop; triage as the delete-drift class.
If the existing logging didn't capture the deciding fact, add the log line
first and reproduce again — one breadcrumb beats ten speculative fixes. Keep
good ones behind Log.shouldTrace and ship them.
5. The escalation ladder (summary)
Full detail, ReproScript command reference, CGEvent driver, and soak-script
method: edmund-live-repro-and-diagnostics and docs/dev-guides/live-repro-guide.md.
Work down; stop at the first level that reproduces.
- Plain unit test (
makeEditor()) — model/parsing/styling logic. - Windowed unit test (NSWindow + NSScrollView, real
deleteBackward) — adds layout, viewport, first-responder. Failure to repro here is evidence, not defeat: it points at deferred/queued AppKit state and at levels 3–4. - In-process ReproScript — DEBUG builds accept
-debug.reproScript <path>(Sources/edmd/App/ReproScript.swift); replays a keystroke script through the realwindow.sendEventpath. No Accessibility/TCC needed, works with the window on an invisible Space. Commands:sleep,caret,type,backspace,bypassdelete,assertcaret,logsel. Launch with-ApplePersistenceIgnoreState YESand recreate the test document fresh each run. The default for live bugs. - CGEvent driver — only for paths that must originate as HID events (drag-select, drag-move, autoscroll). TCC decides per session; if input doesn't land after one test click, fall back to level 3 immediately.
- Instrumented field occurrence — can't trigger it yourself: add the decisive breadcrumb, ask the user to enable verbose tracing, and wait. Days of latency; make sure the next occurrence is decisive.
After a fix: freeze the repro script, run a soak (several trigger cycles in
one app run with assertcaret checks), then full swift test.
6. Open-bug inventory
Known open bugs — check here before "discovering" one. Sources:
misc/backlog.md (authoritative list) and docs/ROADMAP.md (larger themes,
e.g. "TextKit 2 viewport stabilization" is a v1.0.0 item — viewport estimate
glitches are a known, partially-mitigated class). All entries below are OPEN
as of 2026-07-05.
| Bug | Status | Repro asset |
|---|---|---|
| Delete caret drift (class) | Open as a class; rounds 1–6 fixed, watching for round 7 | misc/bug-repros/delete-caret-drift-{1.mp4,2.mov,3.mov,4.mov} + matching logs |
| Inaccurate viewport estimates & related | Open class; small-doc mitigations shipped, Bug-2 repair unconfirmed live | — |
| Callout as last element renders an extra colored line | Open; live incremental restyle path only, NOT static rendering | misc/bug-repros/callout-extra-line-rendered-at-bottom.mov |
| Image creates large empty space below | Open | misc/bug-repros/image-blank-after.mov |
| Footnotes don't render (edit or read mode) | Open | — |
| Math environments don't render in read mode | Open | misc/bug-repros/math-baseline-read-mode-png.png (related baseline issue) |
| Math environments have wrong padding in edit mode | Open | — |
| Max content width not applied to read mode | Open | — |
| Images should shrink when content size is small | Open | — |
| Tables should wrap when content size is small | Open | — |
| Table cell content wraps out of the cell | Open | — |
| Click-to-select / select+delete sometimes doesn't work | Open, intermittent | — |
| Scroll glitch from height changes outside viewport | Open, unreproduced ("Lurking" in backlog) | — |
| Cursor stuck at indented position after indented editing | Open, unreproduced; awaiting screen recording | — |
| Undo/Redo and Copy/Paste scrolling "failing again" | Open, unreproduced (post-fix recurrence report) | — |
Do not relabel any of these as fixed without a verified repro flip; no
oversell. When you fix one, update misc/backlog.md and this table in the
same branch.
7. House rules while debugging
- Never blanket
pkill -x edmd. The user's daily-driver app shares the binary name.pgrep -lx edmd+ps -o lstart=,command= -p <pid>, then kill only your own PID (pkill -f EdmundDbgif you launched the debug bundle). - Do not request Computer Access — Screen Recording and Accessibility are already granted.
- Measure visuals from
screencapturepixels (capture by window id, see ARCHITECTURE §8), never from headless layout alone. - Never mutate storage while
hasMarkedText()— including in any diagnostic or repro code you add. - Never auto-push, PR, or merge. Branch off
mainper fix; commit small and often. - Logs in
~/.edmund/logsare app-owned and fair game to read and quote.
Provenance and maintenance
Built 2026-07-05 from: docs/ARCHITECTURE.md (§2, §8, §9),
docs/investigations/delete-drift-investigation.md (rounds 1–6),
docs/investigations/viewport-glitch-investigation.md,
docs/investigations/archives/callout-title-wrap-investigation.md, docs/dev-guides/live-repro-guide.md,
misc/backlog.md, docs/ROADMAP.md, and
Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift. Log strings
(healing storage edit that bypassed didChangeText, repairing content above origin, recovered stranded desync on focus regain), launch-flag keys,
fullLayoutMaxLength, ReproScript commands, the TK1 tripwire, and commit
5bb2b40 were verified against the source tree on that date.
Maintain it like the codebase docs: when a new bug class is understood, add
its triage row; when a trap costs real time, add its story to §3; when a
backlog bug opens or closes, sync §6 with misc/backlog.md in the same
branch. If a row's discriminating check stops matching the code (renamed log
string, moved file), fix the row — a stale runbook is worse than none.
Deeper mechanism detail belongs in the sibling skills and docs/
investigation files, not here; this file stays a triage surface.
.agents/skills/edmund-docs-and-writing/SKILL.md
npx skills add I7T5/Edmund --skill edmund-docs-and-writing -g -y
SKILL.md
Frontmatter
{
"name": "edmund-docs-and-writing",
"description": "Documentation of record for the Edmund repo: which doc owns which fact, and how to write in the house style. Load whenever you are writing or updating ANY project doc — docs\/ARCHITECTURE.md, CHANGELOG.md, README.md, docs\/ROADMAP.md, misc\/backlog.md, a docs\/<topic>-investigation.md write-up, release docs — or deciding WHERE a newly learned fact, gotcha, bug, or feature idea belongs. Covers the docs-of-record map, the fact-routing decision table, the investigation-doc template, CHANGELOG format (machine-extracted for release notes), commit-message conventions, and doc maintenance duties. Not for making the code change itself, release mechanics, or debugging — see \"When NOT to use this skill\"."
}
Edmund docs and writing
Date-stamped 2026-07-09. Every claim below was verified against the files on
main at that date; re-verify paths before trusting this after major
reorganizations.
When NOT to use this skill
| You are actually doing | Use instead |
|---|---|
| Changing code / designing a mechanism | edmund-architecture-contract |
| Branch/commit/PR mechanics, pre-commit checklist | edmund-change-control |
| Cutting a release, appcast, Sparkle, CI | edmund-release-and-operate |
| Diagnosing a bug (not writing it up) | edmund-debugging-playbook, edmund-live-repro-and-diagnostics |
| Mining past investigations for technique | edmund-failure-archaeology |
| Marketing copy, positioning, alternatives research | edmund-external-positioning |
| Build flags, env, debug bundle | edmund-build-and-env, edmund-config-and-flags |
This skill is for prose: what to write, where it lives, how it should read.
1. The docs-of-record map — one home per fact
Every fact has exactly one home; everywhere else gets a pointer. All paths exist and are current as of 2026-07-09.
| Doc | Owns | Notes |
|---|---|---|
docs/ARCHITECTURE.md |
HOW the system works: build/test commands (§1), the two invariants (§2), render pipeline (§3), edit/undo flow (§4), TextKit 2 drawing (§5), feature map (§6), settings (§7), gotchas (§8), known issues (§9), code debt (§10), agent quick start (§11), working agreements (§12), release/CI (§13), references (§14) | THE agent-onboarding doc. Its own header states the rule: when you learn something non-obvious or change an invariant, edit this file in the same PR. |
docs/architecture/README.md |
Human developer overview: what Edmund is, the two invariants (summarized, not owned), a map of docs/architecture/'s deep docs and the sibling investigations//dev-guides/ folders, common quirks (each a pointer, never a new claim), getting-started commands |
The human entry point ARCHITECTURE.md's header note links to. Every fact here traces to ARCHITECTURE.md or a deep doc — this file summarizes, never owns. |
docs/architecture/<topic>.md |
Deep narrative write-up of one subsystem (e.g. editor-pipeline.md, text-system.md) |
The "deep-doc" pattern: a fact's statement lives in ARCHITECTURE.md, its explanation lives here, each links to the other. |
docs/architecture/extensibility.md |
The design-of-record for themes/extensions: vision, current state (verified against main and the unmerged feat/extensions-registry-and-tab branch), themes/extensions design, staged implementation plan, honest risks |
Design only, not yet implemented on main. ARCHITECTURE.md gets no extensibility section until code lands (same-PR rule) — this doc is the exception to the deep-doc pattern above: there is no ARCHITECTURE.md statement to expand yet. |
docs/architecture/sandboxing.md |
The App Sandbox preparation plan: CotEditor reference model, touchpoint-to-fix inventory, entitlements/build-variant mechanics, the ~/.edmund/ onboarding grant, staged plan (SB0-SB4), open decisions |
Plan only, nothing sandboxed on main. Same design-doc exception as extensibility.md: no ARCHITECTURE.md statement exists yet; when a stage lands, its facts move to ARCHITECTURE.md in the same PR. |
README.md |
WHAT/WHY for users: differentiators, screenshots, install (incl. the Gatekeeper "DAMAGED" xattr -dr com.apple.quarantine workaround), dependencies, alternatives, acknowledgements, license |
User-facing; no internals. |
CHANGELOG.md |
User-facing version history, Keep-a-Changelog style | ## [x.y.z] sections are machine-extracted for release notes — exact format matters (§4 below). |
docs/ROADMAP.md |
Versioned feature plan: ## v1.0.0, ## v1.x, # v.2.0.0 sections of checkbox lists, grouped by theme (editing, extensions, macOS integrations) |
Has a Last updated: YYYY-MM-DD line under the title — refresh it when you edit. |
misc/backlog.md |
The maintainer's working priority list: ## Now (small releases) (Marketing / On-going bugs / Bugs / UI/UX / Features), ## Next, ## Later, roadmap mirrors, ### Lurking (Unreproduceable), ## Done |
Stated priority: Marketing = Bugs >= UI/UX > Features. Bug entries carry repro pointers (misc/bug-repros/*.mov, .log, or ~/Desktop paths). |
docs/investigations/<topic>-investigation.md |
Deep multi-round investigation chronicles for active bug classes | Existing: delete-drift-, viewport-glitch-investigation.md. Template in §5. |
docs/investigations/archives/<topic>-investigation.md |
Chronicles for closed/resolved bug classes | Existing: callout-bottom-line-, callout-title-wrap-investigation.md. |
docs/dev-guides/live-repro-guide.md |
Method doc: the escalation ladder for reproducing live-app bugs | Referenced from ARCHITECTURE §11. |
misc/before-you-release.md |
Pre-flight readiness checklist | Pairs with how-to-release.md; cross-ref edmund-release-and-operate. |
misc/how-to-release.md |
Release mechanics (CI tag path, local release.sh) |
Same. |
AGENTS.md (root) |
Behavior contract for agents: env, git practices, pre-commit checklist, the comment-at-the-code rule | Short by design; it delegates the "how" to ARCHITECTURE. |
LICENSES/ |
Vendored license texts (currently lucide.txt for the Lucide icon SVGs) |
Add one when vendoring third-party assets. |
Info.plist |
CFBundleShortVersionString + CFBundleVersion — the version of record |
Must match the CHANGELOG section header at release (see misc/before-you-release.md §3). |
Note: misc/backlog.md and docs/ROADMAP.md currently duplicate the
v1.0.0/v1.x/v2.0.0 sections (backlog carries an extended copy). ROADMAP is the
public plan; backlog is the working list. When they disagree, treat ROADMAP as
the versioned commitment and backlog as scratch — and mention the drift to the
maintainer rather than silently reconciling.
2. Where does a new fact go — decision table
Route the fact FIRST, then write. One home; cross-reference from elsewhere.
| You learned / produced | Home | How |
|---|---|---|
| Code quirk, edge case, workaround, non-obvious why | Comment at the code site | House rule (root AGENTS.md): "Document non-obvious behavior... as a short comment at the code itself — not in commits or this file." |
| Architectural gotcha that will bite the next agent | ARCHITECTURE.md §8 |
Bold lead-in bullet + one-line repro/symptom + pointer to any deeper write-up. Same PR as the code change. |
| New known issue / structural constraint | ARCHITECTURE.md §9 |
It has an explicit placeholder: "Add new ones here as you find them — with a one-line repro and a pointer to any deeper write-up in docs/." |
| Code debt / incomplete implementation | ARCHITECTURE.md §10 |
Its footer says: track code-debt here, roadmap items in README/ROADMAP. |
| Changed invariant, new subsystem, new pipeline step | ARCHITECTURE.md §2–§7 (the relevant section) |
Update in the same PR — header rule. |
| Multi-round investigation (2+ hypothesis cycles, live repro work) | New docs/<topic>-investigation.md |
Use the §5 template. ALSO add a one-bullet §8 gotcha summarizing the rule it produced, pointing at the doc. |
| User-visible change (fix/feature/rename) | CHANGELOG.md under the next ## [x.y.z] |
Format in §4. Link the issue and any investigation doc. |
| New bug found (reproducible) | misc/backlog.md under Bugs |
- [ ] Bug: <symptom>. See <repro pointer>. Drop repro assets (video/log) into misc/bug-repros/. |
| New bug found (unreproducible so far) | misc/backlog.md → ### Lurking (Unreproduceable) |
One line + "wait for screen record" style note. |
| Bug that is really code debt (design limitation) | ARCHITECTURE.md §9 |
e.g. the image-on-wrapping-fragment constraint. |
| Feature idea, near-term (next few small releases) | misc/backlog.md (Now/Next/Later) |
Sorted by priority + difficulty within category. |
| Feature idea, versioned/strategic | docs/ROADMAP.md under the right version |
Refresh Last updated. |
| Repro method / debugging technique | docs/dev-guides/live-repro-guide.md |
Method docs, not per-bug chronicles. |
| Release procedure change | misc/how-to-release.md / misc/before-you-release.md + ARCHITECTURE.md §13 |
§13 owns the mechanism + failure modes; misc/ owns the operator checklist. |
| Agent workflow improvement | ARCHITECTURE.md §12 |
Its footer invites this: "If you (the agent) improve this workflow... update this section." |
| Vendored third-party asset | LICENSES/<name>.txt + a feature-map note in §6 |
Follow the Lucide precedent. |
| Deep explanation of an existing subsystem | docs/architecture/<topic>.md |
A fact's statement lives in ARCHITECTURE.md; its explanation lives in the deep doc; each links to the other. |
The same-PR rule is the load-bearing one. Doc updates that ride the code
PR actually happen (see cf10741, b600e12, c4a602b in history); doc
updates deferred to "later" don't.
3. House style
Derived from reading ARCHITECTURE.md and the investigation docs. Match it.
- Dense, specific, evidence-first. State the mechanism and the proof, not vibes. "Verified against that exact API" (§8 Sparkle bullet), timestamps and selection ranges quoted verbatim in investigation docs.
- Bold lead-ins for gotcha bullets, then the explanation:
- **Stale release builds**: .... Scannable list, detail inline. - Backticks for every file, symbol, flag, and command:
`recomposeDirty`,`+EditFlow`(the extension-file shorthand),`-debug.reproScript`. - One-line repro pointers, not embedded essays: "See
misc/bug-repros/image-blank-after.mov", "grep~/.edmund/logsforrepairing content above origin". - Honest status labels. The docs say "unconfirmed live", "theory + targeted repair, not a confirmed kill", "Verification limits (honest gaps)", "the test documents intent; the leap only reproduces under live layout". Never claim verification you didn't do. No oversell.
- Section anchors as cross-refs: "see §8", "ARCHITECTURE §13" — used
across ARCHITECTURE, AGENTS.md, before-you-release.md. If you renumber
sections, grep the repo for
§and fix every reference. - Address "you", the next agent/engineer: "will bite you", "Context for anyone who sees the bug again", "Next time it happens: ...".
- Record what failed, not just what worked — investigation docs keep the overturned theories and the phantom fixes (stale-binary trap) because the dead ends are the reusable knowledge.
Commit messages (from git log --oneline -50)
Mixed but patterned: conventional prefixes dominate for fixes and docs —
fix(scope): ... (scopes seen: editor, layout, scroll, undo,
release-workflow, changelog-to-html), docs: ..., occasional
refactor:, appcast: add Edmund X.Y.Z, release X.Y.Z. Chores and README
work often use plain imperative subjects ("Update README", "Add assets for
README"). Branches: fix/<slug>, docs/<slug>, chore/<slug>. When in
doubt: fix(scope): for behavior changes, docs: for doc-only commits,
plain imperative for chores. Never auto-push, PR, or merge — only when asked.
4. CHANGELOG format — machine-read, get it exact
.github/workflows/release.yml extracts release notes with:
awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}"
So the section header MUST be ## [x.y.z] at line start, version matching
CFBundleShortVersionString exactly; the section ends at the next ## [.
scripts/changelog-to-html.py converts the same section to HTML for
Sparkle's update dialog (it folds wrapped bullet lines into their <li> —
wrapping bullets is safe). Full pipeline: edmund-release-and-operate.
House format (verify against the file; current entries follow this):
## [0.1.4] — 2026-07-XX
### Fixed
- <User-facing symptom, past tense optional> ([docs](docs/<topic>-investigation.md)) [#NNN](https://github.com/I7T5/Edmund/issues/NNN)
---
- Em dash between version and ISO date;
---separator between versions. - Subsections used so far:
### Added,### Changed,### Fixed(Keep a Changelog 1.1.0 vocabulary). - Entries describe the user-visible effect, not the mechanism; mechanism lives in the linked investigation doc / ARCHITECTURE.
- An optional free-text line under the header is fine (0.1.2 has one) — the awk extraction includes it.
5. The investigation-doc template
Derived from docs/investigations/delete-drift-investigation.md (6 rounds) and
docs/investigations/viewport-glitch-investigation.md. Both open with why the doc exists
("Context for anyone who sees the bug again... records the trail end to
end") and name the fixing commits/branch up front. Chronicle structure: each
recurrence is a new ## Round N appended to the same doc — symptom →
diagnosis → root cause → fix → verification, with limits stated.
Skeleton (copy-paste):
# <Area> "<bug nickname>" — investigation notes
Context for anyone who sees this again. <One line on why it was hard:
intermittent / state-dependent / looked nothing like its cause.>
Fixed on branch `fix/<slug>`, commits: `<sha>` — <subject>, ...
## Symptom
<Exact user-visible behavior. Bulleted key properties, each a discriminating
fact ("caret-only, text fine"; "never right after launch"). Evidence
pointers: `misc/bug-repros/<file>`, `~/.edmund/logs/...`.>
## How it was diagnosed
1. <Numbered steps in the order they happened, including overturned
theories and WHY each clue narrowed the space.>
## Root cause
<The mechanism, in bold where it matters. Explain why every symptom
property follows from it.>
## The fix
<What changed, in which file, and why that shape (defenses tried and
rejected count too).>
## Verification
<Tests added, live repro results, suite count. Then an honest limits
subsection: what was NOT reproduced/confirmed, and the breadcrumb to grep
for if it recurs.>
## If it ever recurs
<Ordered checks for the next investigator: which invariant/log/flag to
inspect first.>
## Round 2: <one-line summary> ← append on recurrence, same structure
After writing one: add the one-bullet gotcha to ARCHITECTURE §8 with a
pointer, add the CHANGELOG entry with a ([docs](docs/...)) link, and check
the corresponding misc/backlog.md box (or move it under On-going bugs).
6. Maintenance duties
Do these whenever you touch the relevant doc; they rot otherwise.
- ARCHITECTURE placeholders: §9 and §10 end with italic "Add new ones here" / "track code-debt here" lines — keep them last in their lists so the invitation stays visible.
- ROADMAP
Last updated:— bump the date on any edit. - Backlog hygiene: check
- [x]boxes when a fix ships (move to## Doneonly if following the existing pattern — completed items live there); keep repro pointers valid; don't reorder the maintainer's priority sorting. - README's inline HTML comments are the maintainer's own edit notes
(e.g.
<!-- Replace "minimal" with ... -->) — leave them unless acting on them. - At release: CHANGELOG section header ↔
Info.plistversion ↔ appcast<item>must agree; the checklist ismisc/before-you-release.md, the mechanicsedmund-release-and-operate. - Section renumbering in ARCHITECTURE: grep the whole repo (docs, misc,
AGENTS.md, skills) for
§references before and after. - Never edit
test-files/todo.md— the maintainer owns it.
Provenance and maintenance
Written 2026-07-05 against main at fe8a1f5 (release 0.1.3). Sources, all
read directly: docs/ARCHITECTURE.md (header, §8–§13),
README.md, CHANGELOG.md, docs/ROADMAP.md, misc/backlog.md,
docs/investigations/delete-drift-investigation.md, docs/investigations/viewport-glitch-investigation.md,
docs/dev-guides/live-repro-guide.md (§1), misc/before-you-release.md,
misc/how-to-release.md, root AGENTS.md,
.github/workflows/release.yml (awk extraction quoted verbatim),
git log --oneline -50 (commit-style tally), directory listings of
docs/ (architecture/, investigations/ incl. archives/, dev-guides/),
misc/, misc/bug-repros/, LICENSES/.
§1 map re-verified 2026-07-09 against the docs/ reorg (investigation docs
split into docs/investigations/ + docs/investigations/archives/;
docs/live-repro-guide.md moved to docs/dev-guides/).
Maintain this skill when: a doc of record moves or splits (update the §1
map), ARCHITECTURE sections are renumbered (fix every § reference here),
the CHANGELOG extraction in release.yml changes (§4 quotes it), or a new
investigation doc establishes a better template. Keep the one-home-per-fact
rule itself stable — it is the point of the skill.
.agents/skills/edmund-external-positioning/SKILL.md
npx skills add I7T5/Edmund --skill edmund-external-positioning -g -y
SKILL.md
Frontmatter
{
"name": "edmund-external-positioning",
"description": "Load when writing anything an outsider will read about Edmund — README edits, blog posts, release notes, marketing copy, social posts, webpage text, Show HN drafts — or when comparing Edmund to other editors (Typora, Obsidian, MarkEdit, Nodes), deciding what may be publicly claimed vs. what is still unproven, labeling a technique \"novel\", or planning ecosystem work (licenses, attribution, notarization messaging, appcast, issue templates, discovery listings). This skill is the overclaim firewall: what the positioning is, what evidence backs each claim, and what must exist before a claim gets stronger."
}
Edmund external positioning — what we claim, what we can prove
Governing rule: nothing may be claimed publicly that an outsider cannot reproduce from the repo + a release. Unproven = "candidate", never "novel" or "first". This skill exists to prevent overselling a beta.
When NOT to use this skill
| You are doing | Use instead |
|---|---|
| Actually cutting a release (tags, appcast, Sparkle, CI) | edmund-release-and-operate |
| Internal docs, ARCHITECTURE.md, investigation chronicles | edmund-docs-and-writing |
| Understanding the invariants / render pipeline itself | edmund-architecture-contract |
| Deciding whether a code change is allowed | edmund-change-control |
| Reproducing or debugging a bug | edmund-debugging-playbook, edmund-live-repro-and-diagnostics |
| Judging research novelty for internal direction (not public claims) | edmund-research-frontier, edmund-research-methodology |
| Verifying behavior before shipping | edmund-validation-and-qa |
1. The positioning (quote it exactly)
Source of truth: README.md. As of 2026-07-05:
- One-liner: "Edmund is a minimal, file-based, native Markdown editor for
macOS with inline live preview."
- README carries its own TODO comment on this line: "Replace 'minimal' with 'customizable' or 'lightweight' once more features are implemented" — do not do that replacement early; "minimal" is the honest word today.
- Goal statement: "Our goal is to be the CotEditor of Markdown editors, i.e. elegant, powerful, configurable, and native inside out."
- Beta warning: "⚠️ Edmund is currently in beta." — this must stay visible in the README and any landing page until v1.0.0 ships.
- Maintainer's blog post: https://i7t5.com/posts/2026-06-26-edmund/ ("more of the motivation and design philosophy"). Cite it; do not paraphrase or invent its content without fetching it.
- Ambition framing: product-first. "Beyond state of the art" means product excellence — the TextKit 2 techniques are means, not ends. Never lead public copy with internal mechanism names; lead with what the user gets.
The six claimed differentiators (README, verbatim, 2026-07-05)
| # | Claim (verbatim) |
|---|---|
| 1 | "Live preview: Typora/Obsidian-style WYSIWYG." |
| 2 | "File-based: Open .md files from anywhere. No vaults or dedicated folders required." |
| 3 | "Native: 100% Swift. Based on AppKit and TextKit 2. No Electron. Minimal dependencies." |
| 4 | "Fast: Handles ~1-2MB files with ease. No launch lag." |
| 5 | "Extensible: Opt-in math and Obsidian syntax. Extensions system coming soon!" |
| 6 | "Private: Offline by default. Optional blocking of external links and HTML sanitization." |
README also has a TODO comment after the list: "Move 'Fast' and 'Extensible'? Add 'integrations' section to Native after implementation" — the list is known-provisional; keep quotes synced to the file when you edit copy.
2. Claims discipline
Before strengthening any claim publicly, the evidence in the middle column must be upgraded to the right column. Status as of 2026-07-05.
| Claim | Current evidence | Required before strengthening |
|---|---|---|
| Fast: "~1-2MB files with ease. No launch lag" | Tests/EdmundTests/PerfHarnessTests.swift (gated MD_PERF=1, default 1.5MB doc, prints latencies; assertions are deliberately "sanity bounds, not budgets"); viewport-first lazy styling with fullLayoutMaxLength = 100_000 regime (EditorTextView.swift:80) |
A reproducible public benchmark: pinned document + hardware noted + numbers an outsider can rerun (MD_PERF=1 swift test). No comparative "faster than X" claims without benchmarking X the same way. |
| Extensible: "Extensions system coming soon!" | Extensions API is docs/ROADMAP.md v1.0.0 — not shipped. Only opt-in math + Obsidian syntax exist today. README already hedges with "coming soon" |
Keep it hedged until the API + docs + at least one working extension ship. Never write "extensible via plugins" in present tense. |
| Private: "Offline by default" | Grounded: Read webview disables JavaScript; all assets inlined as data URIs (math, icons, local images); remote images off by default (ReadRenderOptions.allowRemoteImages = false); inline HTML whitelisted via HTMLRenderer.sanitizeInlineHTML |
Note: the "Block external images" Settings checkbox is a misc/backlog.md item, not shipped; "optional blocking of external links" in README is forward-leaning — verify against code before repeating it elsewhere. Exceptions to name if asked: Sparkle update check, opt-in crash-log upload (§7 ARCHITECTURE). |
| Native: "100% Swift … No Electron. Minimal dependencies" | True: SwiftPM, three deps (swift-markdown, SwiftMath, Sparkle) + vendored Lucide SVGs. Read mode is a WKWebView (system WebKit, JS off) — that is not Electron, but don't say "no web views" | Nothing; this claim is safe. Just never inflate to "zero dependencies". |
| Live preview: "Typora/Obsidian-style" | Shipped and demoed (README video, screenshots) | Safe. Comparative feature-parity claims vs. Typora/Obsidian need a feature-by-feature check first. |
| File-based: "No vaults" | Shipped by design | Safe. |
| Beta status | v0.1.3 (2026-07-04) | Must stay visible everywhere until v1.0.0. |
3. Novel vs. known — the honest inventory
When writing a craft blog post or comparison, keep this ledger straight. "Candidate" means blog-worthy pending proof; it is not "proven novel".
Known / prior art (never claim novelty here)
- Live-preview Markdown editing: Typora, Obsidian, MarkText, Nodes. MarkEdit is the stated reference for source mode (ROADMAP v2.0.0 "the MarkEdit experience").
- TextKit 2 viewport virtualization for Markdown: nodes-app/swift-markdown-engine (Apache 2.0, macOS 14+) solves the same problems — viewport virtualization, live styling, wiki links, reading column, LaTeX. Per ARCHITECTURE §14: consult it before inventing a new mechanism and as a technique source (drag-select autoscroll, overscroll). Its existence caps any "first TK2 live-preview engine" claim at zero.
- The README's own Alternatives section credits ~15 editors. Public copy that ignores them reads as either ignorant or dishonest.
Distinctive candidates (label as such; each needs proof before publishing)
| Candidate | Why it might be blog-worthy | Proof needed first |
|---|---|---|
| Attribute-only rendering with the storage == rawSource invariant (no attachment characters, no U+FFFC; delimiters hidden, never stripped; identity offset mapping) | A clean architectural answer to the classic WYSIWYG mapping problem | A survey showing how the named alternatives (incl. swift-markdown-engine) handle storage vs. display; the invariant's consequences demonstrated with runnable examples |
Stroked-CGPath overlay workaround for the TK2 image wedge (image in a fragment overlay collapses the fragment's layout to one line; callout icon drawn as stroked path from vendored Lucide SVG instead) |
A concrete, reproducible TK2 bug + workaround — the classic useful engineering post | A minimal frozen repro of the wedge outside Edmund; macOS version range where it reproduces |
Bypassed-didChangeText heal + caret re-assertion (round-6 mechanism: TK2 leaves a _fixSelectionAfterChange queued after a bypassed edit; next-run-loop sync check heals storage and re-asserts the caret) |
Deep TK2 internals nobody has documented; the delete-drift chronicle (docs/investigations/delete-drift-investigation.md) already exists as raw material |
The frozen ReproScript repro kept green; behavior confirmed on current macOS before publishing (private-method behavior can change under us) |
| Diff-based undo restore that preserves TK2 layout (snapshot restore diffs rather than replaces, bypassing NSTextView undo) | Practical fix for a visible TK2 pain (undo viewport yank) | Before/after measurements (layout work saved, viewport stability) on a pinned document |
In-process ReproScript methodology (-debug.reproScript, keystroke replay without CGEvents/TCC; docs/dev-guides/live-repro-guide.md) |
Reusable testing methodology for any AppKit text app | Show it reproducing a real bug end-to-end in a fresh checkout; that IS the reproducibility standard |
Reproducibility standard for any technical post: a reader with the repo and the post must be able to reproduce every claim — frozen repro scripts, pinned document fixtures, named macOS versions, measured numbers with the command that produced them. If a claim can't meet that, cut it or mark it anecdotal.
4. Ecosystem and license hygiene
Verified against the repo, 2026-07-05:
- License: Apache 2.0 (
LICENSE, README "License" section, and the 0.1.0 changelog entry all agree). Say "Apache 2.0", never "MIT". - Lucide icons: vendored, ISC (
LICENSES/lucide.txt, © 2026 Lucide Icons and Contributors; parts derived from Feather). Attribution duty: keepLICENSES/lucide.txtshipping and credit Lucide where icons are discussed. - Why Lucide in both modes (SF Symbols constraint): ARCHITECTURE §6 —
SF Symbols cannot ship in exported PDFs (license), so callout headers use
Lucide in both Read (inline SVG,
currentColor-tinted) and Edit (rasterized tintedNSImageoverlay). App-chrome SF Symbols (toolbar/settings) are fine; Edit-mode task checkboxes still use SF Symbols on-screen only. Don't "simplify" copy or code in a way that breaks this split. - Dependencies to credit: swift-markdown, SwiftMath, Sparkle (README "Dependencies"). Acknowledgements section additionally credits swift-markdown-engine/Nodes, Typora, theme sources, create-dmg, MarkEdit, and others — preserve it when restructuring the README.
- Not notarized (2026-07-05): ad-hoc signed; users hit Gatekeeper
("damaged app"). README's WARNING block gives the two workarounds
(System Settings → Privacy & Security → Open Anyway; or
xattr -dr com.apple.quarantine /Applications/Edmund.app, maybesudo). Keep those instructions accurate in every venue that mentions installing.misc/marketing/MARKETING.mdgates Show HN on fixing this (notarize, or make the workaround idiot-proof). - GitHub issue templates exist:
.github/ISSUE_TEMPLATE/bug_report.md,feature_request.md. Point users there, not at email. appcast.xmlis a public artifact served raw from the repo (SUFeedURLpoints at the raw GitHub URL). Anything committed to it is user-visible in Sparkle's update dialog. Pipeline details: edmund-release-and-operate.
5. Release-notes and public-writing style
- Pipeline:
CHANGELOG.mdsections become both the GitHub release notes (awk-extracted) and Sparkle's update-dialog HTML (scripts/changelog-to-html.py→ appcast<description>). A CHANGELOG entry IS public copy — write it that way. Mechanics: edmund-release-and-operate. - Actual house style (read
CHANGELOG.md0.1.0–0.1.3 before writing): Keep-a-Changelog headers (### Added / Changed / Fixed); one line per item, sentence case, no trailing period enforced; links to issues ([#156]) and investigation docs (([docs](docs/investigations/delete-drift-investigation.md))); user-visible phrasing ("Redo now jumps to where changed text was instead of caret") not internal jargon; occasional first-person maintainer notes with personality ("trying to have Fable 5 fix all the big bugs while I still have it with me"); 0.1.0 used bold Feature — one-line descriptions. Match this voice: plain, specific, lightly informal, zero hype. - Screenshots/videos: README embeds live in
docs/assets/(v0.1.0_*.png,installation.png,v0.1.0_video.mp4,AppIcon/). Raw/source marketing assets live inmisc/marketing/:MARKETING.md(the plan),reddit-post.md,demo-slide-v0.1.key,demo-src-files/, demo videos (demo.mov,demo-video-v0.1-brown.mp4),_rawscreenshot masters,social-preview_figma.png. New public screenshots: polished copy →docs/assets/, raw master →misc/marketing/, versioned filenames.
6. Marketing priority context (2026-07-05)
From misc/backlog.md "Now": "Priority: Marketing = Bugs >= UI/UX >
Features" — marketing work is tied for top priority with bug fixes.
Open marketing items: screenshots/files and a webpage (reference:
kruszoneq.github.io/macUSB). Backlog embeds a star-history.com chart;
misc/marketing/MARKETING.md names GitHub stars (~69 at writing) as the goal
and metric, audience "developers who value craft", and holds Show HN in
reserve until first-run friction and a landing page are fixed. Its "craft
months" deep-dives are exactly the Section 3 candidates — which is why the
proof bar there matters.
Provenance and maintenance
- Sources verified 2026-07-05 against:
README.md,docs/ROADMAP.md(last updated 2026-07-03),misc/backlog.md,docs/ARCHITECTURE.md(§2, §6, §8, §13, §14),CHANGELOG.md(0.1.0–0.1.3),LICENSE,LICENSES/lucide.txt,.github/ISSUE_TEMPLATE/,misc/marketing/,Tests/EdmundTests/PerfHarnessTests.swift,Sources/EdmundCore/Export/{ReadRenderOptions,HTMLRenderer,DocumentHTML}.swift,Sources/EdmundCore/TextView/EditorTextView.swift. - Volatile facts are date-stamped inline: version (0.1.3), beta status, notarization status, shipped-vs-roadmap feature split, star count, README wording. Re-verify each against the file before repeating it publicly.
- When README differentiators or the one-liner change, update the verbatim quotes in §1 and re-run the §2 evidence check.
- If a §3 candidate ships as a published post, move it out of "candidate" and link the post + its frozen repro.
- Cross-references: edmund-release-and-operate (release/appcast mechanics), edmund-docs-and-writing (internal doc style), edmund-research-frontier (novelty judgment for research direction), edmund-architecture-contract (the invariants quoted here).
.agents/skills/edmund-failure-archaeology/SKILL.md
npx skills add I7T5/Edmund --skill edmund-failure-archaeology -g -y
SKILL.md
Frontmatter
{
"name": "edmund-failure-archaeology",
"description": "The chronicle of every major bug investigation, dead end, rejected fix, and revert in the Edmund Markdown editor. Load BEFORE re-investigating any caret\/selection symptom (drift, jump, desync), any viewport\/scroll glitch (lurch, oscillation, wrong landing, can't-scroll-up), any rendering wedge (clipped wrap, one-line collapse, blank space), or any release\/update failure (signing, appcast, Sparkle \"improperly signed\"). Load before proposing a fix that might already have been tried and reverted, when a bug report \"looks familiar\", when a test passes but the live app still misbehaves, or when wondering why the code does something weird (a guard, a re-assert, a deliberately-missing icon). Every entry: symptom, root cause, evidence (commit hashes, docs), status, and what NOT to retry."
}
Edmund failure archaeology
Chronicle of settled battles. Purpose: nobody re-fights one. Each entry gives
symptom → root cause → evidence → status. Hashes are on main unless noted.
Dates are commit dates. Status vocabulary: settled (root-caused, fix
verified), mitigated-unconfirmed (fix shipped, never seen killing a live
occurrence), open (in misc/backlog.md), reverted-pending-redo.
When NOT to use this skill
- Designing a change / asking "why is it built this way" →
edmund-architecture-contract. - Actively debugging a NEW symptom (method, not history) →
edmund-debugging-playbook. - Driving the live app to reproduce something →
edmund-live-repro-and-diagnostics(anddocs/dev-guides/live-repro-guide.md). - TextKit 2 / AppKit API semantics →
textkit2-appkit-reference. - Cutting or fixing a release →
edmund-release-and-operate(this file only records how 0.1.0 broke). - Build environment, stale-link traps in depth →
edmund-build-and-env. - Deciding whether a change is safe to make at all →
edmund-change-control. - Test strategy / what the suite can and cannot catch →
edmund-validation-and-qa. - The ongoing caret-integrity program (forward-looking) →
edmund-caret-integrity-campaign. - Debug flags (
-debug.reproScript, verbose tracing toggles) →edmund-config-and-flags.
1. The delete-drift saga (rounds 1–6) — issue #156
The hardest bug in the project's history: pressing Delete moved the caret to a
different line instead of deleting. Six rounds, 2026-06-25 → 2026-07-04.
Full trail: docs/investigations/delete-drift-investigation.md (read it before touching
+EditFlow / +SelectionTracking / the heal). One symptom, FOUR distinct
root causes stacked on top of each other — each fix was real, and each round's
recurrence was a different mechanism underneath.
STATUS: settled through round 6 (shipped in 0.1.3, 2026-07-04). The class — live-only caret/selection desync — remains the project's hardest problem; new rounds are possible. Backlog still lists "Delete caret drift" under On-going bugs as a class to watch, not a known unfixed defect.
Round 1 — stranded IME marked text (2026-06-26, 386604b + docs ef3d87e)
- Symptom: once it started, EVERY delete drifted; never at launch; cleared by switching apps and back. Text stayed correct — caret-only desync.
- Root cause: every styling path bails on
hasMarkedText()(correct during live IME composition). A stranded composition (hasMarkedText()stuck true, no live composition) madedidChangeTextbail forever →rawSource/blocksfroze while storage kept mutating → all caret math ran against a stale model. Strander: the async active-block restyle in+SelectionTrackingre-checkedisUpdatingbut nothasMarkedText(), so it could runrecomposeDirtyover a live composition scheduled one turn earlier. - Fix: (a) add the missing
!hasMarkedText()guard to the async restyle; (b)becomeFirstResponderrecovery hook —unmarkText()+ resync when the invariant is broken on focus regain (made the user's accidental focus-switch cure deterministic). - Why it came back: the guard closed one strander; other marked-text sources existed (round 2), and other desync mechanisms entirely (rounds 4–6).
Round 2 — marked text without "IME" (2026-06-27, a1f3219)
- Symptom: recurred with no CJK/accent/emoji input. Focus-switch still cured it.
- Root cause (by elimination, documented in the doc): still stranded marked text — from automatic text completion / inline predictions, which inject provisional marked text on plain typing.
- Fix:
isAutomaticTextCompletionEnabled = false,inlinePredictionType = .noincommonInit, plus a permanentLog.infobreadcrumb in the recovery hook. - Why it came back: the next recurrence wasn't marked text at all.
Round 3 — no fix; built diagnostics instead (2026-06-28, 5dae387, 3aaeb04, PR #139)
- Symptom: recurred on a build with rounds 1–2. NO
recovered stranded desynclog line; a headless probe of the exact gesture showed the model was CORRECT. Only appeared after minutes of editing in one window. - Conclusion: the model/parse layer is sound; the drift is a live NSTextView / TextKit 2 / input-context phenomenon invisible headless. Chasing it blind was declared the wrong move.
- Shipped: verbose editor tracing (Settings ▸ Advanced,
Log.trace, category.edit, per-event live-state prefix) + an always-on O(1) invariant tripwire (verifyEditorInvariants, logserroron length mismatch). This instrumentation is what cracked rounds 4–6. Lesson: when a live-only bug resists reproduction, ship diagnostics, not guesses.
Round 4 — drag-move deletes with NO didChangeText (2026-07-02, 9f99795, PR #163)
- Symptom (from the round-3 trace): drifting deletes showed
shouldChangeText→ nothing →selectionDidChangemid-recompose with a stale caret. Origin event: a drag-select, thenshouldChangeText OK repl="", then LEN-MISMATCH forever —didChangeTextnever fired. - Root cause: AppKit's drag-move gesture, when the drop lands past the
end of the document (or fumbles), performs the source deletion via
shouldChangeText→replaceCharactersand never callsdidChangeText.rawSourcesilently froze — and autosave wrote the stale bytes: this was a data-corruption bug, not just a caret bug. - Fix:
shouldChangeTextschedules a next-run-loop bypass check (RunLoop.main.perform): apendingEditstill unconsumed one pass later == didChangeText was bypassed → run the same sync it would have, loghealing storage edit that bypassed didChangeText(release too). Exempt whileisUpdating/isUndoRedoing/hasMarkedText()(IME legitimately defers). - Why it came back: the heal restored the model but rounds 5–6 found the heal itself could move the caret.
Round 5 — the heal leaped the caret (stale selection) (2026-07-03, 422498f, docs c4a602b, merged 222dd86, PR #166)
- Symptom: heal fired, invariant restored, bytes correct — but the caret leaped to the END of the document at the heal moment.
- Root cause: when the bypassed deletion removes the selected text, AppKit also skips its usual selection fix, so at heal time the selection still spans deleted text (e.g. {951,37} in a 973-char doc). The heal's restyle makes AppKit re-resolve the invalid selection → clamps to document end.
- Fix: before syncing, the heal collapses an out-of-bounds selection to the edit point. (Headless NSTextView clamps this itself — the test documents intent; only live layout reproduces the leap.)
- Why it came back: this out-of-bounds clamp was a special case of the real mechanism, found in round 6.
Round 6 — TextKit 2's queued selection fixup: the drift mechanism itself (2026-07-04, 1b1420a, branch fix/wrapped-paragraph-caret-drift, merged 218d922, PR #169)
- Symptom: typing mid wrapped paragraph, one backspace leaped the caret +43 ("two viewport-lines down"); drift no longer continuous — one delete drifts, the next ones don't. Model fine; a heal had fired 80 seconds EARLIER.
- Root cause (named via a
traceSelectionOriginstack capture): a normal edit runs TextKit 2's_fixSelectionAfterChangeInCharacterRangesynchronously inside its own transaction. A didChangeText-bypassing mutation skips that too, so the fixup stays queued and fires at the NEXTendEditing— the heal's attribute-only restyle — where it maps the stale selection against post-edit coordinates and drops the caret blocks away. Fires exactly once (state is then synchronized), explaining "drifts once, then fine". Round 5's clamp was the sub-case where the stale selection ran past the shrunk document end. - Fix: the heal derives the correct caret from the pendingEdit hull and
sets it both before AND after
syncRawSourceFromDisplay(). The before-only version still leaped — the queued fixer moves even a freshly set, fully valid caret during the sync'sendEditing. The post-sync re-assert is the load-bearing half; the pre-set keepscursorRaw/active-block styling correct. - The breakthrough repro (first deterministic one in six rounds):
ReproScript.swift(DEBUG-only,-debug.reproScript <path>) replays keystrokes in-process throughwindow.sendEvent(_:)— no TCC, works on an invisible Space.bypassdelete <needle>simulates the drag-move deletion exactly; one bypass beforehand → the next delete always drifts. Typing alone never drifts. Seeedmund-live-repro-and-diagnostics.
Do not retry (proven dead across the saga)
- Headless/unit tests for this class. The test harness runs TextKit 2's selection fixups synchronously, so the deferred-fixup state never forms. The round-6 unit test passes with and without the fix — it is a contract spec, not a regression guard. Only the ReproScript live repro discriminates. Do not "add a test to catch it" and call the class covered.
- CGEvent injection as the default live driver. Round 6's session dropped the events (per-session TCC), and the app's windows launch on an inactive Space. Use ReproScript first.
- DEBUG assertion in
didChangeText's marked-text guard — rejected in round 1: the invariant is legitimately broken during composition; it false-fires on every IME keystroke. - "No explicit selection repair needed" (round 4's claim) — wrong twice. Any new heal-like path must handle selection explicitly, before and after.
swift buildtrusted after "Build complete!" — round 6 hit a stale-link relink failure TWICE; two "failed" fix iterations were phantoms running old code. Verify withstringson a LONG literal (≤15-byte literals inline on arm64 and never show), cure withswift package clean, never hand-deleteedmd.build/. Details:edmund-build-and-env.
2. Undo/redo viewport drift — the costliest failure
STATUS: settled (2026-07-02, 5bb2b40, part of PR #164) — with one caveat:
misc/backlog.md "Lurking (Unreproduceable)" carries a later note "Undo/Redo
and Copy/Paste scrolling is failing again". No repro exists. Treat the
mechanism below as settled and any new report as a NEW investigation that
starts from this entry.
- Symptom: undo scrolled too far down; redo centered on wherever the caret sat before the undo; changed text never selected.
- Root cause (two defects, found by code read before any experiment):
restoreSnapshotran a fullrecompose— replacing the entire storage discards every TextKit 2 layout fragment, resetting ALL geometry to height estimates; the subsequent centering math measured estimates.performUndorecorded the redo snapshot with the caret at undo invocation time (stale), and redo centered on it.
- Fix:
textDiff(old:new:)single contiguous changed span → range-boundedrecomposeReplacing(layout outside the span stays real); the changed range — never a stored caret — is selected and drives the viewport (hold if visible, else center). - Load-bearing contract: never full-recompose on undo/redo. Anyone
"simplifying"
restoreSnapshotback torecomposereintroduces the bug. Guarded byTextDiffTests,UndoRedoSelectionTests,UndoRedoViewportTests. - Prior art that treated symptoms without naming the estimate problem:
9aaa11b(undo hold-or-center),2778d6e/21cc284(cursor-move lurch),84123e4(pin scroll above viewport),c49cd5c(lazy viewport-first styling). All sound, all workarounds;5bb2b40removed the manufactured estimates at the source.
3. Viewport glitches — TextKit 2 height estimates (PR #164, 2026-07-02)
Full trail: docs/investigations/viewport-glitch-investigation.md. Three reported symptoms,
one root cause: every off-screen TextKit 2 frame is an estimate; code that
discards layout, trusts off-screen y, or runs two scroll policies at once turns
estimate churn into visible jumps. Community-documented (Krzyżanowski /
STTextView, Apple forums) — even TextEdit exhibits it.
- Bug 1 (undo/redo): entry 2 above. STATUS: settled (same caveat).
- Bug 2 (editing at top pushes line 1 above the viewport, can't scroll up).
Theory: TK2 assigns fragments negative y when estimates above the
viewport correct downward; scroller clamps at 0. Mitigations
217da5f(documents ≤100k UTF-16 kept fully laid out via a deferredscheduleFullLayoutSettle— estimates never exist) and8b4ecfe(repairContentAboveOrigin: first fragmentminY < -0.5→ re-lay start→viewport-end insidepreservingViewportAnchor; breadcrumbrepairing content above origin). STATUS: mitigated-unconfirmed. The doc is explicit: Bug 2 was never reproduced live; the repair had not been confirmed against a live occurrence as of this writing (2026-07-05). If it recurs: grep~/.edmund/logsfor the breadcrumb — present means diagnosis confirmed but repair raced/undersized; absent means different cause (scroller-only estimate jumps, ortextContainerOrigin). - Bug 3 (viewport oscillates during a steady drag-select). Two scrollers
fighting: drag autoscroll pulling down vs the
scrollRangeToVisibleoverride always revealing the selection top once the selection outgrew the viewport. Fix340fcbc: reveal the nearest end. STATUS: settled by geometry/reasoning — a live drag was never synthesized (that session couldn't arm AppKit selection). Phase 1 of the same report ("can't select") was NOT a bug: a whole-doc selection was active, so the drag was AppKit's drag-move gesture — same family as delete-drift round 4. - Do not retry: raising
fullLayoutMaxLengthwithout measuringensureLayoutcost (full layout on large docs is the process-killing path that motivated the lazy pipeline); running a full layout inside a caller'spreservingViewportAnchor(poisons its before/after measurement — the tab-indent stability test caught a 366pt compensation; that's why the settle is deferred). - Backlog keeps "Inaccurate viewport estimates and things related" under On-going bugs, plus a lurking "glitch when scrolling" — the estimate class is managed, not extinct. Roadmap v1.0.0 carries "TextKit 2 viewport stabilization".
4. Callout-title wrap / the image wedge (settled 2026-07-03, aa45563 + ae61644, PR #165)
Full trail: docs/investigations/archives/callout-title-wrap-investigation.md — including a 10-row
matrix of dead ends.
- Symptom/goal: custom callout titles (
> [!type] Title) should render as real wrapping text with the type icon; any attempt to draw the icon clipped the wrapped title to one line. - Root cause: drawing any IMAGE on a multi-line layout fragment wedges
that fragment to a single line — an unexplained TextKit 2 reentrancy quirk.
Isolated exhaustively: fragment overlay, frame-relative draw, before/after
super.draw, rawCGContext.draw, editor-leveldraw(_:), pre-rasterized bitmap, transparent subview, layer-backed subview, CALayercontents— ALL clip. Controls: no icon → wraps; positions computed but plain rect filled instead of the image → wraps. Reading layout is fine; drawing a shape is fine; drawing an image is not. - Fix: the icon is a stroked
CGPath—SVGPathparses the vendored Lucide geometry,FragmentOverlaygained a path form,DecoratedTextLayoutFragmentstrokes it in CG. Verified live: icon renders, long titles wrap and re-wrap on resize. - Standing constraint: any future overlay that can share a line with wrapping text MUST use the path form, never the image form. Existing image overlays (math, bullets, default callout header) survive only because they sit on single-line fragments.
- Do not retry: any image-drawing mechanism from the matrix; bumping the
deployment target to macOS 15 on the hope newer TextKit 2 fixed it (no
evidence, drops Sonoma incl. the dev machine). The whole-header-as-image
alternative is preserved on branch
fix/callout-title-image(tipc7f5170, unmerged): it sidesteps the wedge but has two unsolved problems (~2× line height band above the title; a width-timing race). - Still open nearby (backlog): callout icon baseline / crispness; the callout-at-end-of-file extra colored line (live-path rendering bug).
5. The 0.1.0 release failures (2026-06-26 → 2026-07-02)
Reference: ARCHITECTURE §13. Five separate failures shipping and updating the first releases. STATUS: all settled, with one time bomb (PAT expiry).
sign_update -sexits 1 for new keys (59565f5, 2026-06-27, PR #135). Sparkle deprecated-s <key>; for keys generated after that change it prints a warning and exits 1. Killed the first 0.1.0 release. Fix: key on stdin —echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>. Do not retry-s.- Bundle not sealed → EVERY update failed "improperly signed"
(
5e54b40, 2026-06-29, issue #158). Sparkle re-validates the Apple code signature at install (SUUpdateValidator→SecStaticCodeCheckValidity); the build signed only the main binary, never sealed the bundle, so a valid EdDSA signature didn't save it. Fix:codesign --deepthe whole.app— and because SwiftMath's resource bundle must sit at the.approot (Bundle.modulehardcodesBundle.main.bundleURL) and codesign won't seal a bundle with root items, seal first, copy the SwiftMath bundle in AFTER sealing. The lone unsealed root item trips strictcodesign --verifybut not Sparkle's non-strict check (verified against that exact API). Do not "fix" the ordering or the failing strict verify. - Appcast push to protected
mainrejected,GH006(e56a4dd, 2026-06-28, PR #140).GITHUB_TOKENisn't admin;enforce_admins: falsemeans an admin PAT bypasses the required check. Fix: fine-grained admin PAT in secretRELEASE_TOKEN, set on the checkout step (not the push URL —actions/checkoutpersists anextraheadercredential that overrides inline-URL creds).RELEASE_TOKENexpires 2027-06-27; rotate before then or releases fail at the appcast push. - create-dmg quirks (
098d8c0, 2026-06-26, documented in §8): it's the npm create-dmg (sindresorhus), not the Homebrew tool; Node ≥20; exit code 2 for unsigned images is success; space-in-filename normalization. - Release workflow YAML invalid (
854f85d, 2026-07-02, merged232e6c8, PR #162). Literal multi-line bash strings inside arun: |block had unindented lines — invalid in a YAML block scalar; the whole workflow file failed to parse. Fix: build the strings withprintf\nescapes; also$(...)strips trailing newlines, so the separator newline lives inNEW_ITEM's format string, notDESC_BLOCK.
6. Reverts and abandoned directions
- Selection tint —
ee173f7(2026-06-26): reverted an experimental selection color back to accent-derived (accent @ 30% alpha). STATUS: settled. Don't re-hardcode a bespoke selection color. img.md-image { display:block }in the export/Read HTML theme —75d2824(2026-06-25): reverted; it did NOT fix the image blank-space and broke layout. The commit title itself records the verdict: image blank-space is a separate, STILL-OPEN bug (backlog: "Attached image padding… creates a large empty space below",misc/bug-repros/image-blank-after.mov). Do not retry display:block for it.- Checkbox click-to-toggle + table borders —
9991413(2026-06-03): pulled from a branch for separate passes. STATUS: partially redone. Table work landed later (edit-mode table alignment shipped, per backlog Done); Read-mode click-to-toggle checkbox is still in the v1.x backlog — reverted-pending-redo. The original attempt lives on stale branchfeature/checkbox-toggle(merged history,d6227e3). - TextKit 2 migration regressions —
b3a4b29(2026-06-12): the TK1→TK2 migration silently broke inline-math height, HR spacing, and scroll; fixed withRenderingRegressionTestsas the guard. Lesson: TK2 migrations regress silently in geometry — extend that suite when touching layout. - Stale branches that look abandoned but are MERGED (don't "rescue" them):
feature/incremental-recompose(tip1222f79, in main) andrefactor/word-level-rendering(merged via PR #8,7eb6a21— word-level delimiter hiding became the shipped approach). The genuinely unmerged WIP branches arefix/callout-title-image(entry 4) and dozens of old merged topic branches never deleted.
7. Wrapped-paragraph caret drift (PR #169) — same battle as round 6
fix/wrapped-paragraph-caret-drift / merge 218d922 IS delete-drift round 6
(entry 1): the branch name comes from the reporting symptom (backspace mid
wrapped paragraph), but the root cause was the queued TextKit 2 selection
fixup armed by an earlier bypassed drag-move edit — the wrapped paragraph was
incidental. STATUS: settled with 1b1420a. If a caret drift is reported
"in a wrapped paragraph", do not assume wrapping is the mechanism; check for a
preceding heal breadcrumb in ~/.edmund/logs first.
8. Smaller settled battles (one paragraph each; verified in git)
- Flaky math fit-width test — took TWO rounds (
54212972026-06-06 branchfix/flaky-math-test, then352bdc9PR #65). First fix pinned the text container width (tracking off) — the flake persisted ~1 in 3 runs. Real cause: shared theme-defaults state pollution between tests; fixed with isolated defaults. Lesson: a flake "fix" that doesn't name the shared state isn't done. - Emoji rendered as missing-glyph boxes (
0f5ffba, 2026-06-06).EditorTextStorage.fixAttributesis a deliberate no-op (so.attachmenton real characters survives) — which also disabled font substitution. Fix: perform substitution manually (Apple Color Emoji per composed-character sequence, ZWJ/skin-tone graphemes whole). Don't re-enable the frameworkfixAttributes; it strips the marker attachments. - Nested list hanging indent (
8d2088f, 2026-06-06, PR #64). swift-markdown's list-item delimiter excludes leading indentation; the visible spaces broke the hang. Fix: hide the leading whitespace in the inactive branch; indentation comes entirely from the paragraph style. - Ordered-list deep nesting lost styling (
b255903, 2026-06-06). swift-markdown parses ≥4-space indent as indented code; the rescue regex only matched[-*+]. Extended to\d{1,9}[.)]. Any new list-ish syntax must be added to the rescue parser too. - Window size persistence — three commits to get right (
538ff6e→d967bcf→678c5d6, 2026-06-28, PR #144). Saving content-view size made windows grow taller on reopen (titlebar double-counted); final form stores the full window frame and restores viasetFrame. - Toolbar right-click interception — three failed view-level attempts
(
4eb604a,6723280, thenf8472ca, 2026-06-26). View.menu,rightMouseDown, and a gesture recognizer all lost to the toolbar's "Customize Toolbar…" menu. Working fix:DocumentWindowoverridesNSWindow.sendEvent— the documented funnel ahead of the toolbar — and swallows secondary clicks on the view-mode button. Do not retry view-level interception for anything the titlebar/toolbar claims. - Invisible CJK input (
a3df387): IME-composed text was invisible until committed; fixed by keeping marked text visible. Related to (and predating) the round-1 marked-text rules.
9. Still OPEN — do not let this chronicle imply otherwise
Per misc/backlog.md (cross-checked 2026-07-05): footnotes don't render
(edit or Read); math doesn't render in Read mode; math padding in edit mode;
image blank-space below attached images (see the 75d2824 revert);
callout-at-end-of-file extra colored line; max content width not applied to
Read mode; tables don't wrap/shrink at small content sizes; table-cell content
wraps out of the cell; "sometimes click to select / select+delete doesn't
work" (unreproduced); lurking scroll glitch from off-viewport height changes;
lurking indented-cursor-stuck report; lurking "undo/redo and copy/paste
scrolling failing again" (see entry 2 caveat). Delete-caret-drift and
viewport-estimate classes stay on the watch list even though every known
mechanism is fixed.
Provenance and maintenance
- Written 2026-07-05 by mining:
docs/investigations/delete-drift-investigation.md,docs/investigations/viewport-glitch-investigation.md,docs/investigations/archives/callout-title-wrap-investigation.md,docs/ARCHITECTURE.md§13,CHANGELOG.md,misc/backlog.md,docs/ROADMAP.md, andgit log --all(every hash above verified withgit showon that date). - Code and git win over prose. If this file disagrees with a commit or the current source, trust the commit, then fix this file.
- Update triggers: a new delete-drift round (append to entry 1 — never a
new doc); any live confirmation or refutation of
repairContentAboveOrigin(flip entry 3 Bug 2 off mitigated-unconfirmed);RELEASE_TOKENrotation (entry 5); any revert (entry 6); closing a backlog bug named in entry 9. - Keep the status vocabulary exact; "mitigated-unconfirmed" is not "fixed". No oversell — this file's value is that its claims can be trusted blind.
- Sibling map lives in "When NOT to use" above; keep it in sync as the skill library grows.
.agents/skills/edmund-live-repro-and-diagnostics/SKILL.md
npx skills add I7T5/Edmund --skill edmund-live-repro-and-diagnostics -g -y
SKILL.md
Frontmatter
{
"name": "edmund-live-repro-and-diagnostics",
"description": "How to MEASURE Edmund instead of eyeballing it — the diagnostic tools, interpretation guides, and working scripts. Load when a bug involves LIVE behavior (caret, IME, drag, viewport timing), when a unit test cannot reproduce a report, when you need to read diagnostic traces, drive the running app with scripted keystrokes, or measure pixels from a screenshot. Contains the repro escalation ladder, the ReproScript driver, the CGEvent fallback, and screencapture measurement, plus scripts\/ helpers. Not the symptom→mechanism table (edmund-debugging-playbook), not the campaign (edmund-caret-integrity-campaign), not build hygiene (edmund-build-and-env)."
}
Edmund live repro & diagnostics
A class of Edmund bugs lives in the live NSTextView / TextKit 2 / input-context
layer (deferred selection fixups, IME composition, drag sessions, event-loop
timing). Headless tests cannot form the broken state — the test harness runs
AppKit's deferred machinery synchronously, so a green unit test proves nothing
about this class. This skill is how you make such a bug cheap to observe, then
deterministic. Primary source doc: docs/dev-guides/live-repro-guide.md.
Verified 2026-07-05.
0. Safety preamble (do this every time)
- Check for the user's live instance first. The user's daily-driver app has
the same binary name (
edmd). Runscripts/check-live-instance.sh. Never blanketpkill -x edmd— kill only your own PID, or usepkill -f EdmundDbg(only your debug bundle matches). - Recreate the test document fresh before every run — autosave mutates it; run 2 against run 1's leftovers produces garbage offsets.
- Verify the binary is fresh before trusting a run (SwiftPM sometimes prints
Build complete!without relinkingedmd) — strings/shasum method in edmund-build-and-env. - Do not request macOS Computer Access. Screen Recording + Accessibility are already granted for this project; ReproScript (§3) needs neither.
1. The escalation ladder
Work down; stop at the first level that reproduces. Each is more faithful and more expensive than the one above.
| # | Technique | Faithful to | Cost | Use when |
|---|---|---|---|---|
| 1 | Plain unit test (makeEditor()) |
model/parse/style logic | seconds | anything not event-timing-dependent |
| 2 | Windowed unit test (NSWindow + NSScrollView, real deleteBackward(nil)) |
+ layout, viewport, first responder | seconds | viewport/lazy-styling bugs (LazyRenderingTests setup) |
| 3 | In-process ReproScript (§3) | + real key path, run-loop pacing, real process | ~1 min/run | anything keyboard/edit-pipeline shaped — the default for live bugs |
| 4 | CGEvent driver (§4) | + real HID events, real mouse (drags!) | TCC-dependent | mouse-only paths: drag-select, drag-move, autoscroll |
| 5 | Instrumented field occurrence | everything | days | can't trigger it — instrument first (§2), decide on the next hit |
Two levels deserve emphasis:
- Level 2 failing to repro is evidence, not defeat — it tells you the bug needs deferred/queued AppKit state, pointing you at level 3–4.
- Level 3 exists because level 4 is unreliable — background/agent sessions often have no TCC grant and synthetic keyboard events get dropped silently. In-process injection needs no permission.
2. Step 0 — make the trace tell you the trigger
Never script blind. The recipe is usually already in ~/.edmund/logs, if
verbose diagnostics were on. Launch flags (file arg must be argv[1]):
<app>/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES
Trace-field decoder (each verbose line carries these; from
EditorTextView+Diagnostics.swift):
| Field | Meaning |
|---|---|
sel |
current selection {location,length} |
active |
active (caret) block index |
marked |
is there marked/IME text |
up |
isUpdating — up=Y = event arrived mid-recompose (suspicious) |
undo |
undo-stack depth |
blocks |
block count |
storLen / rawLen |
storage length vs rawSource length |
- Healthy edit ordering:
shouldChangeText→selectionDidChange(up=N) →synced. A transient⚠︎LEN-MISMATCHbetween those lines is normal (storage moves before rawSource syncs). - Suspect: a
selectionDidChangewithup=Yat a surprising position; a persisting LEN-MISMATCH; ashouldChangeTextwith nosynced/SKIPPED/DEFERREDafter it (a bypasseddidChangeText); thehealing storage edit that bypassed didChangeTextbreadcrumb. traceSelectionOriginlogs a call stack for any selection change that lands mid-recompose — this is what named_fixSelectionAfterChangein round 6.- Walk BACKWARDS from the first bad line, not forwards from the symptom. The round-6 drift was armed ~80 seconds and dozens of healthy edits before the visible failure. The user-visible symptom is often the second half.
If the log didn't capture the deciding fact, add the log line first (keep
good ones behind Log.shouldTrace and ship them) and reproduce again. Also
reconstruct the document — wrapped-paragraph geometry, block boundaries, and
block kinds all matter; repro against a lookalike, never "hello world".
scripts/grep-trace.sh [YYYY-MM-DD] surfaces the suspect patterns in one shot.
3. The in-process ReproScript driver (default for live bugs)
Sources/edmd/App/ReproScript.swift, DEBUG builds only. Replays a keystroke
script against the front document by synthesizing NSEvents and pushing them
through window.sendEvent(_:) — the full authentic key route (keyDown →
interpretKeyEvents → insertText: / deleteBackward:). No Accessibility, no
visible window required (works on an inactive Space), real run-loop pacing.
Launch: scripts/launch-debug.sh FILE.md SCRIPT.repro (assembles EdmundDbg.app,
guards the user's instance, direct-execs with all flags). Or by hand:
build/EdmundDbg.app/Contents/MacOS/edmd "$DOC.md" \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES \
-debug.reproScript "$SCRIPT.repro" \
-ApplePersistenceIgnoreState YES &
Command surface (one per line, # comments allowed):
| Command | Effect |
|---|---|
sleep <ms> |
wait before the next command |
caret <needle> |
place caret before the first occurrence of <needle> |
type <text> |
one real key event per char, ~80 ms apart |
backspace <n> |
n real delete keystrokes, ~300 ms apart |
bypassdelete <needle> |
simulate the drag-move source deletion: select range, shouldChangeText + storage mutation, no didChangeText |
assertcaret <needle> |
log repro assertcaret PASS/FAIL sel=… want=… iff caret sits exactly before <needle> |
logsel |
log selection, rawSource length, doc count |
Round-6 minimal repro (the worked example): the deciding output was logsel
321 (broken) → 290 (fixed), every run, window not even visible.
sleep 2000
bypassdelete Sizemore,
sleep 800
logsel # broken: {321,…}; fixed: {290,…}
backspace 2
logsel
Design rules — keep them when extending:
- Address text by needle, never offset — offsets go stale the moment a script edits; needles survive (this is what makes soak scripts possible).
- Real events over direct method calls —
insertText("")shortcuts skipdeleteBackward's selection machinery, the exact place round 6 lived. - Simulate AppKit-internal paths by exact call sequence —
bypassdeletereplicatesshouldChangeText→replaceCharacters, nodidChangeTextverbatim, not an approximation. Pin any new internal path's real sequence from atraceSelectionOriginstack first, then replay it. - Asserts inside the app, results in the log — the harness (you, or a shell
loop) only greps
PASS/FAIL; the app is the oracle. - New commands are ~10 lines each — extend
ReproScript.swift, don't work around it.
Soak scripts (§6): chain several trigger cycles at different positions with
ordinary editing between them, assertcaret after each predictable step, and
compare final rawLen across runs (byte-identical = deterministic). A soak green
across 4–5 cycles is far stronger than one clean repro — it catches bugs needing
armed state (round 6's queued fixup).
bypassdelete Sizemore,
assertcaret Strang
backspace 2
type xy
bypassdelete widely
assertcaret used in various
logsel
4. CGEvent driver (mouse-only paths, TCC willing)
For paths that must originate as HID events — real drag-select, drag-move,
autoscroll — keyboard replay can't cover them. A ~70-line ui.swift (compile
with swiftc) posting CGEvents does: bounds <substr> (window lookup via
CGWindowListCopyWindowInfo), click x y, dragselect, dragmove (mousedown +
~400 ms hold before moving, or AppKit never arms the text drag), key,
type.
Caveats (all hit in practice):
- TCC decides per session. Background/agent sessions often can't post
keyboard events (dropped silently) or use System Events. Test with **one click
- log check**; if input doesn't land, fall back to §3 immediately — don't iterate on driver variations.
- App windows are on an inactive Space until activated
(
kCGWindowIsOnscreen == false);osascript -e 'tell application "<path>.app" to activate'(Apple Events, a separate TCC bucket) may work where System Events is denied. - Re-activate before every interaction batch; focus is lost between shell calls.
5. Screencapture measurement
Visual judgments are measured, not eyeballed — when the task says "balance padding" or "align the icon", capture the window and measure pixels.
scripts/capture-window.sh <window-title-substring> out.png finds the window id
(JXA → CGWindowListCopyWindowInfo) and runs screencapture -x -o -l<id>.
Notes:
- Capture by window id, reliable even when not frontmost.
- Crop by the detected window bounds — the desktop wallpaper defeats screencapture's brightness-based auto-crop.
- Measure padding/alignment from the PNG (e.g. a short Python/PIL pixel scan for the first/last colored row of a callout box). Report the pixel numbers, not an impression.
- Window-server state can glitch (tiny windows, restoration) after many rapid
launch/kill cycles:
rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedStateand relaunch.
6. The loop, end to end
- Verbose trace from the occurrence → find the first bad line, walk backwards, form a trigger hypothesis (§2).
- Reconstruct the document; script the hypothesized trigger (§3).
- No repro? Hypothesis wrong or fidelity too low — move down the ladder (§1), or instrument and wait for the next hit.
- Repro in hand? Freeze it (exact script + document), then let it falsify fix candidates — round 6's first "fix by reasoning" failed in the repro within a minute.
- Fix verified → soak (§3) → full
swift test→ keep the script + new diagnostics → update the relevantdocs/*-investigation.md.
Meta-lesson from six rounds: time spent making the failure cheap to observe beats time spent reasoning about the fix. Every round that shipped on reasoning alone came back; the round that shipped on a deterministic repro named the actual mechanism.
scripts/ (in this skill dir)
| Script | Purpose | Status |
|---|---|---|
check-live-instance.sh |
Report running edmd, exit 2 if any; never kills |
logic verified; safe by construction |
grep-trace.sh [date] |
Surface suspect patterns in today's log | logic verified |
capture-window.sh <needle> <out.png> |
Screenshot a window by id + report bounds | verify on first use (JXA CGWindowList lookup not executed this session) |
launch-debug.sh <file.md> [script.repro] |
Build + assemble EdmundDbg.app + direct-exec with flags | verify on first use (assumes arm64 debug triple; guards user instance) |
All four pass bash -n. The two "verify on first use" scripts depend on live
system state (window server, build layout) that couldn't be exercised while
authoring; read the header comment before first run.
When NOT to use this skill
- Deciding which mechanism a symptom implies → edmund-debugging-playbook.
- Running the full caret-integrity investigation → edmund-caret-integrity-campaign.
- Stale-binary detection / bundle internals → edmund-build-and-env.
- What counts as sufficient evidence to ship → edmund-validation-and-qa.
- The AppKit theory behind the fixup/marked-text mechanisms → textkit2-appkit-reference.
Provenance and maintenance
Verified 2026-07-05 against docs/dev-guides/live-repro-guide.md,
Sources/edmd/App/ReproScript.swift, and
Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift.
grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
grep -rn 'traceSelectionOrigin\|LEN-MISMATCH\|shouldTrace' Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift Sources/EdmundCore/Diagnostics/Log.swift
grep -n 'healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
Re-verify the scripts against docs/dev-guides/live-repro-guide.md §4 if the debug-bundle
assembly recipe changes.
.agents/skills/edmund-release-and-operate/SKILL.md
npx skills add I7T5/Edmund --skill edmund-release-and-operate -g -y
SKILL.md
Frontmatter
{
"name": "edmund-release-and-operate",
"description": "Load when cutting or debugging an Edmund release, or operating the shipped app. Triggers: version bump (Info.plist CFBundleShortVersionString \/ CFBundleVersion), tagging vX.Y.Z, CHANGELOG.md release sections, release.yml \/ release.sh \/ build-app.sh, appcast.xml or Sparkle update failures (\"improperly signed\", update never offered), sign_update \/ EdDSA keys \/ SPARKLE_ED_PRIVATE_KEY \/ RELEASE_TOKEN, create-dmg or DMG naming problems, Gatekeeper \"damaged\" reports, launching the built app, reading ~\/.edmund\/logs, crash reports (edmd-*.ips), or roadmap\/priority questions."
}
Edmund — release & operate
Date-stamped 2026-07-05. Verified against .github/workflows/release.yml,
scripts/release.sh, scripts/build-app.sh, scripts/changelog-to-html.py,
appcast.xml, Info.plist, CHANGELOG.md, docs/ARCHITECTURE.md §8/§13, and
the Settings/CrashReporter sources. Where a doc and a script disagree, the
script is the truth; disagreements are flagged inline.
House rule: releases happen only when the maintainer explicitly asks.
Never tag, push, create a release, or merge on your own initiative — see
edmund-change-control. Everything in §1–§4 below is a runbook for when the
maintainer says "cut a release", not a standing instruction.
When NOT to use this skill
| You actually need | Go to |
|---|---|
| Build/test commands, stale-build cures, launch mechanics in depth | edmund-build-and-env |
| Editing invariants, render pipeline, TextKit 2 rules | edmund-architecture-contract, textkit2-appkit-reference |
| Debugging a bug in the app itself | edmund-debugging-playbook, edmund-live-repro-and-diagnostics |
| Past incidents and why the sharp edges below exist | edmund-failure-archaeology |
| Debug flags / launch arguments | edmund-config-and-flags |
| Branch/commit/PR etiquette, what needs maintainer sign-off | edmund-change-control |
| Pre-merge QA method | edmund-validation-and-qa |
| README/website/positioning copy | edmund-docs-and-writing, edmund-external-positioning |
1. Release flow — CI path (the normal one)
Ship via a tag; CI does the rest. In order:
- Bump versions in
Info.plist— both keys:CFBundleShortVersionString— marketing version, e.g.0.1.3CFBundleVersion— build number, monotonic integer (0.1.3 =4)
- Add a
## [x.y.z]section toCHANGELOG.md— format is load-bearing, see §2. The version MUST match Info.plist exactly. - Merge to
mainand push (via the normal PR flow). - Tag and push the tag:
git tag vX.Y.Z && git push origin vX.Y.Z - CI (
.github/workflows/release.yml, triggerpush: tags: 'v*', runnermacos-14, jobrelease/ "Build & publish") runs the steps below. - Afterwards, verify per §4 post-flight.
release.yml step anatomy (actual step names)
| Step | What it does | Sharp edge |
|---|---|---|
actions/checkout@v5 |
fetch-depth: 0, token: ${{ secrets.RELEASE_TOKEN }} |
The PAT must be on this step — see §3.4 |
maxim-lobanov/setup-xcode@v1 |
latest-stable Xcode | |
| Cache .build | SPM cache keyed on Package.resolved |
|
| Build app bundle | ./scripts/build-app.sh — release build, bundle assembly, Sparkle embed, bundle sealing |
§3.2 |
actions/setup-node@v4 |
pins Node 20 | create-dmg 8.x needs Node ≥ 20 |
| Install create-dmg | npm install --global create-dmg (sindresorhus/create-dmg, not Homebrew's) |
§3.5 |
| Create DMG | reads VERSION from Info.plist, create-dmg build/Edmund.app build/ || true, renames "Edmund <v>.dmg" → Edmund-<v>.dmg, fails loudly if no dmg |
§3.5 |
| Sign archive (EdDSA) | finds sign_update in .build, key on stdin via --ed-key-file -, exports ED_SIG and LENGTH |
§3.1 |
| Create GitHub Release | awk-extracts the CHANGELOG section → gh release create "v${VERSION}" build/Edmund-${VERSION}.dmg --title "Edmund ${VERSION}" --notes-file … --latest |
§2 |
| Update appcast.xml | builds the new <item> (HTML <description> via scripts/changelog-to-html.py), inserts it before </channel>, commits as github-actions[bot], git push origin HEAD:main |
§3.4 |
Local path (scripts/release.sh)
Mirrors CI: build-app.sh → create-dmg (+ rename) → EdDSA sign → update
appcast.xml locally → gh release create. Two differences:
- Signing: with
SPARKLE_ED_PRIVATE_KEYin the env it uses the stdin path (CI-style); otherwisesign_updatepulls the key from the login keychain (put there by Sparkle'sgenerate_keys) with no flag at all. - The appcast commit/push is left to you. The script ends with the exact
commands:
git add appcast.xml && git commit -m 'Release <v>' && git push.
Prereqs for the local path: gh auth status authenticated, npm create-dmg
installed, swift build has run at least once (so sign_update exists under
.build).
Stale doc:
misc/how-to-release.mdstill says the artifact is a zip ("signs the zip", "Zip it to build/Edmund-1.0.zip"). That predates the DMG switch. The truth is DMG throughout — perrelease.yml,release.sh, and ARCHITECTURE §13. Trust the scripts, and fix that doc when touching it.
2. CHANGELOG format contract (release notes are machine-extracted)
Both release.yml and release.sh extract the GitHub Release body with:
awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" CHANGELOG.md
So the section header must start at column 0 as ## [x.y.z] — literally
## [0.1.3] — 2026-07-04 in house style (em dash + ISO date after the
bracket is fine; the match only requires the ^## \[x.y.z\] prefix).
Extraction runs until the next ^## [ line. If nothing matches, the release
body falls back to "See CHANGELOG for details." — a silent-ish failure, so get
the header right. (Version dots are unescaped in the regex; harmless in
practice, don't rely on it.)
The Sparkle update-dialog notes come from the same section via
scripts/changelog-to-html.py <version>, a deliberately tiny converter that
only understands Keep-a-Changelog shapes:
### Added/### Changed/### Fixed→<h3>— use###, not##. The 0.1.2 appcast item literally shows<p>## Changed</p>because the section used##subheads at release time; the converter passed them through as paragraphs.-/*bullets →<ul><li>; indented continuation lines fold into the previous bullet.`code`and**bold**are converted. Markdown links are NOT —[docs](docs/foo.md)appears literally in the update dialog (see the 0.1.2 item). Keep appcast-facing notes link-free or accept the raw brackets.- Blank lines and
---are skipped; anything else becomes a<p>. - Missing section → empty output → the
<description>is simply omitted.
House format (verified from CHANGELOG.md): Keep a Changelog 1.1.0 + SemVer,
newest first, sections separated by ---.
3. The sharp edges (each one killed or nearly killed a real release)
3.1 sign_update -s is FATAL — key goes on stdin
Sparkle deprecated -s <key>; for newly generated keys it prints a
deprecation warning and exits 1 ("no longer supported"). This killed the
first 0.1.0 release. The only correct invocation with a key-in-hand:
echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>
Both release.yml and release.sh do exactly this. Never "simplify" it back
to -s. Output format: sparkle:edSignature="<sig>" length="<n>" — the
scripts grep those two attributes out for the appcast item.
3.2 Bundle sealing (the "improperly signed" update failure)
At install time Sparkle re-validates the update's Apple code signature
(SUUpdateValidator), independent of the EdDSA signature. A bundle that is
code-signed but not sealed (no _CodeSignature/CodeResources) fails that
check and every update dies with "The update is improperly signed and could
not be validated" — which is exactly what broke the v0.1.0 → 0.1.1 update
when the old script signed only the bare binary.
build-app.sh therefore signs inside-out and in a very deliberate order:
codesign --force --deep --sign - Sparkle.framework(nested XPC helpers must be signed before macOS will launch them),codesign --force --deep --sign - --identifier "com.i7t5.edmd"on the whole.appwhile its root holds onlyContents/— codesign refuses to seal a bundle with extra items at the root,- copy the SwiftMath resource bundle to the
.approot after sealing (its generatedBundle.modulelooks atBundle.main.bundleURL; without it the app crashes on the first LaTeX render).
Consequence: codesign --verify (CLI) and --strict will complain about
that one unsealed root item. That is expected and fine — Sparkle's actual
check is non-strict (SecStaticCodeCheckValidityWithErrors with
kSecCSCheckAllArchitectures) and tolerates it; verified end-to-end against
that API. Do not "fix" the verify warning by moving the SwiftMath bundle or
re-signing after the copy.
3.3 Keypair discipline
One EdDSA keypair, three places, all of which must agree:
| Place | Used by |
|---|---|
Info.plist SUPublicEDKey (0XdLbbuO…) |
Every shipped app, to verify updates |
| Maintainer's login keychain | release.sh local signing (no flag) |
GitHub secret SPARKLE_ED_PRIVATE_KEY |
CI signing |
If the signing key and SUPublicEDKey diverge, everything looks fine — the
DMG signs without error — but every user's update fails signature
verification. Sanity check when in doubt:
sign_update --verify <dmg> <sig> against the Info.plist public key.
3.4 Appcast push to protected main — RELEASE_TOKEN
The workflow's last step commits appcast.xml and pushes to main, which
requires the test status check. The default GITHUB_TOKEN /
github-actions[bot] is not an admin, so that push is rejected with
GH006 … protected branch hook declined. Branch protection has
enforce_admins: false, so an admin's push bypasses the check — hence the
fine-grained admin PAT in the RELEASE_TOKEN secret (Contents:
read/write), set as the token: on the checkout step, not on the push.
That placement matters: actions/checkout persists an
http.<host>.extraheader credential that overrides inline-URL credentials,
so rewriting the push URL would keep pushing with the bot token anyway.
RELEASE_TOKEN expires 2027-06-27. Rotate it before then or every
release fails at the appcast push while the GitHub Release itself succeeds
(a confusing half-shipped state — see §4 post-flight).
3.5 create-dmg quirks
- It's the npm package
create-dmg(sindresorhus), installed vianpm install --global create-dmg. Homebrew'screate-dmgis a different tool with an incompatible CLI. Requires Node ≥ 20 (CI pins it). - It exits 2 when it can't Developer-ID-sign/notarize the image (Edmund
ships ad-hoc) but still produces the .dmg. Both scripts run it with
|| trueand then verify the file exists, failing loudly only if no dmg was produced. Don't remove the|| true; don't trust the exit code. - Output is named
"Edmund <version>.dmg"— with a space. Both scripts rename toEdmund-<version>.dmg(hyphen), which is the name the appcast enclosure URL expects. If a rename is skipped, the release asset URL 404s for every updater.
4. Pre-flight and post-flight
Pre-flight (distilled from misc/before-you-release.md — read it too)
-
swift testgreen onmain, not just the branch;git statusclean. - No debug flags / launch args left on (repro drivers, verbose tracing —
see
edmund-config-and-flags, ARCHITECTURE §8). - Visual sanity: build and screencapture the editor in light and dark mode; click through everything the CHANGELOG claims ("fixed X" → actually reproduce X and confirm).
-
CHANGELOG.mdhas## [x.y.z] — YYYY-MM-DDfor this release and the version matches Info.plist (CFBundleShortVersionString);###subheads, not##(§2). -
CFBundleVersionbumped (monotonic int). -
RELEASE_TOKENnot expired (2027-06-27). - Local path only:
gh auth statusok; keychain key matchesSUPublicEDKey(§3.3).
Post-flight
- GitHub Release
vX.Y.Zexists with the right notes and theEdmund-<v>.dmgasset (hyphenated name). -
appcast.xmlonmaingot the new<item>— with<description>, correctsparkle:version(= CFBundleVersion) and enclosure URL. - Nothing to do for user prompts: Sparkle checks roughly daily; existing users see the update within ~24 h. Don't panic if it isn't instant.
If the Release exists but the appcast commit is missing, the release
half-shipped (usually §3.4). Fix the token, then add the <item> manually or
re-run the job.
5. Gatekeeper story (why users see "damaged")
Edmund is ad-hoc signed, not notarized (no $99/yr Developer ID). First launch of a downloaded copy trips Gatekeeper with the "app is damaged" dialog. This is expected; the app is fine. The README documents both workarounds (verified, README ~line 53):
xattr -dr com.apple.quarantine /Applications/Edmund.app, or- right-click → Open.
Known upgrade path (open/candidate, not scheduled): Developer ID certificate
- notarization would remove the prompt entirely and also clean up the non-strict-sealing compromise in §3.2. Don't promise it in user-facing text.
6. Operating the app
Launching
open Edmund.app foregrounds a running instance instead of relaunching —
you'll stare at old code. And never pkill -x edmd blindly: the maintainer's
own Edmund session may be running (the Mach-O is edmd for both). Check
first (pgrep -x edmd), kill only PIDs you started, or launch the binary
directly: build/Edmund.app/Contents/MacOS/edmd file.md &. Full launch /
stale-build / screencapture mechanics: edmund-build-and-env.
Logs — ~/.edmund/logs/edmund-YYYY-MM-DD.log
- One file per day, human-readable lines tagged
LEVEL [category](categories: app, document, io, render, compose, selection, lazy, callout, edit — grep by concern). - Controlled by Settings ▸ Advanced ▸ Diagnostics ("Save diagnostic
logs"). The toggle defaults OFF (
AppSettings.diagnosticLoggingdefaults false) — i.e. opt-in in the shipped app, despiteLog.swift's header comment calling it "always-on (opt-out)"; the code is the truth. (The UserDefaults keys are namedsettings.general.*for legacy reasons; the UI lives in Advanced.) - Retention picker ("Clear logs after:") next to the toggle; a separate
"Verbose editor tracing" opt-in gates keystroke-level trace lines — leave
off except during repros (
edmund-live-repro-and-diagnostics). - Release builds write
infoand up; DEBUG builds also writedebug. - Logs may contain document text; they never leave the machine.
Crash reports — ~/Library/Logs/DiagnosticReports/edmd-*.ips
- macOS names crash reports after the Mach-O executable: look for
edmd-<timestamp>.ips, not "Edmund-…". - Uploading is opt-in and currently INERT. The Settings toggle is
commented out in
Sources/edmd/Settings/AdvancedSettingsView.swift("dormant until the receiving server exists"), andCrashReporter.reportingEndpointis a placeholder (https://REPLACE-ME.invalid/crash). Nothing is ever sent in shipped builds. Don't tell users crash reporting exists; don't uncomment the toggle without a real server. Code:Sources/EdmundCore/Diagnostics/CrashReporter.swift. - Reading works only because Edmund is not sandboxed; adopting App Sandbox would force a MetricKit rewrite (noted in CrashReporter's header).
- Triage of a user's
.ips: it's JSON — a one-line metadata header, then the report body. Look atexception(type/signal),faultingThread, and walk that thread's frames for images namededmdorSparkle. Ad-hoc builds ship no dSYM, so expect addresses rather than symbol names for app frames; correlate with~/.edmund/logsfrom the same timestamp instead..ipsfiles embed the user's home path and device model — treat as mildly personal data.
Update mechanics (user side)
SUFeedURL=https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml— the raw-GitHub URL of the checked-in appcast; committing tomainis publishing.SUEnableAutomaticChecksis true; no custom interval is set, so Sparkle uses its default ~24 h cadence (plus a check on launch).- Sparkle downloads the DMG enclosure, verifies EdDSA against
SUPublicEDKey, mounts the DMG, re-validates the Apple code signature (§3.2), installs.
7. Versioning & appcast conventions
| Thing | Convention | Current (2026-07-05) |
|---|---|---|
| Git tag | vX.Y.Z |
v0.1.3 pending its tag; last released 0.1.2 |
CFBundleShortVersionString |
SemVer marketing version | 0.1.3 |
CFBundleVersion |
monotonic integer, +1 per release | 4 |
| CHANGELOG | Keep a Changelog 1.1.0, ## [x.y.z] — YYYY-MM-DD, ### subheads, --- separators |
— |
appcast.xml (checked into repo root): RSS 2.0 with the sparkle: namespace.
One <channel> (title/link/description/language) containing one <item> per
release. Items are inserted before </channel>, so the file reads oldest
→ newest; Sparkle doesn't care about order — it picks by version. Per item:
<item>
<title>Edmund 0.1.2</title>
<pubDate>Fri, 03 Jul 2026 19:03:59 +0000</pubDate>
<description><![CDATA[ …HTML from changelog-to-html.py… ]]></description>
<enclosure url="https://github.com/I7T5/Edmund/releases/download/v0.1.2/Edmund-0.1.2.dmg"
sparkle:version="3" <!-- CFBundleVersion -->
sparkle:shortVersionString="0.1.2" <!-- marketing version -->
sparkle:edSignature="…"
length="7608991"
type="application/x-apple-diskimage"/>
</item>
<description> is optional (omitted when the CHANGELOG section is missing).
8. Roadmap context (for release-content judgment)
- Edmund is in beta (0.1.x line, first public release 0.1.0 on 2026-06-27). Small, frequent releases.
- v0.2.0 goal: "Polished editing experience" (
misc/backlog.md§ Now). - Priority ordering: Marketing = Bugs >= UI/UX > Features — when deciding what makes a release, bug fixes and polish beat new features.
- Long-range plan (v1.0 = onboarding + full GFM + extensions groundwork):
docs/ROADMAP.md; working backlog with per-bug detail:misc/backlog.md.
Provenance and maintenance
Written 2026-07-05 from direct reads of: .github/workflows/release.yml,
scripts/release.sh, scripts/build-app.sh, scripts/changelog-to-html.py,
appcast.xml, CHANGELOG.md, Info.plist, README.md,
docs/ARCHITECTURE.md (§8, §13), misc/how-to-release.md,
misc/before-you-release.md, docs/ROADMAP.md, misc/backlog.md,
Sources/EdmundCore/Diagnostics/CrashReporter.swift,
Sources/EdmundCore/Diagnostics/Log.swift,
Sources/edmd/Settings/AdvancedSettingsView.swift,
Sources/edmd/Settings/AppSettings.swift.
Known stale docs at time of writing: misc/how-to-release.md (zip vs DMG,
§1); Log.swift header ("always-on (opt-out)" vs the actual default-off
toggle, §6). Minor oddity, deliberate: build-app.sh signs with
--identifier "com.i7t5.edmd" while the bundle id is com.i7t5.edmund.
Re-verify when any of these change: release.yml step names or secrets,
build-app.sh signing order, the CHANGELOG header format (the awk regex in
two places must match it), SUFeedURL, RELEASE_TOKEN rotation (hard
deadline 2027-06-27), notarization status, or the crash-report server going
live (which un-inerts §6's crash uploading and this skill's wording).
.agents/skills/edmund-research-frontier/SKILL.md
npx skills add I7T5/Edmund --skill edmund-research-frontier -g -y
SKILL.md
Frontmatter
{
"name": "edmund-research-frontier",
"description": "Open problems where the Edmund Markdown editor could advance the state of the art — product-first, everything labeled candidate\/open, nothing proven. Load when picking the next big problem, evaluating whether an ambitious idea is worth starting, or answering \"what would move this project beyond state of the art\". Each frontier: why current SOTA fails, Edmund's specific asset, the first three concrete steps IN THIS REPO, and a falsifiable \"you have a result when…\" milestone. Not for running an accepted investigation (edmund-caret-integrity-campaign), the method of proof (edmund-research-methodology), or shipping the change (edmund-change-control)."
}
Edmund research frontier
Where Edmund could go past the state of the art. Ambition is product-first: "the CotEditor of Markdown editors" (README). Advanced TextKit 2 techniques are means, not ends — a technique is worth pursuing when it makes the product better, and it becomes publishable as a side effect.
Everything here is candidate / open. Nothing is proven. Each item routes its proof through edmund-research-methodology (hypothesis predicts numbers) and its changes through edmund-change-control. Verified 2026-07-05 against the repo; assets cited are real, outcomes are not.
Frontier 1 — Viewport-stable TextKit 2 at scale (>100k UTF-16)
Why SOTA fails: TK2 lays out only near the viewport; off-screen fragment
heights are estimates corrected as layout reaches them. This makes the
scroller jump and scroll-to-target miss in every TK2 app, including TextEdit
— a widely documented limitation. Above fullLayoutMaxLength (100k) Edmund
enters this regime.
Edmund's asset: the mitigations already in TextView/ —
scheduleFullLayoutSettle, preservingViewportAnchor, repairContentAboveOrigin,
centerViewportOnCaret re-measure, and the diff-based undo restore that avoids
resetting fragments to estimates; plus the ScrollStabilityTests /
HeightStabilityTests harnesses. (Note: repairContentAboveOrigin is
mitigated-unconfirmed live per docs/investigations/viewport-glitch-investigation.md — a
real result here would also retire that honest gap.)
First three steps in this repo:
- Build a large-doc fixture (
makeLargeMarkdowninTestHelpers.swift) >100k and a scripted scroll-accuracy metric (ReproScriptcaret+ alogsel-style position dump, or extendPerfHarnessTests). - Quantify the estimate-error distribution: for N scroll-to-target operations, record predicted vs actual landing pixel offset.
- Prototype persistent per-fragment height caching across the settle (or across sessions) and re-measure the same distribution.
You have a result when: scroll-to-target lands within a stated pixel budget
on a 1 MB document, measured by script, with zero repairing content above origin events across a scroll soak — reproducibly.
Frontier 2 — Caret integrity by construction
Why SOTA fails: every NSTextView consumer depends on didChangeText
pairing that AppKit itself violates (the drag-move bypass). The delete-drift
class is the symptom of building sync on a callback contract AppKit doesn't keep.
Edmund's asset: the heal machinery, the pendingEdit model, six documented
rounds of mechanism knowledge, and the ReproScript + soak methodology. The
campaign skill runs individual rounds reactively; this frontier is the
structural endgame — eliminate the class.
First three steps:
- Inventory every storage-mutation entry point (grep
replaceCharacters,setAttributes, the edit-flow paths) and tabulate which currently rely on a callback firing. - Design a sync layer keyed on a storage-version counter that reconciles
rawSourceregardless of which callbacks fired (not a new guard per path). - Falsify it against all historical
.reproscripts plus a new randomized bypass-fuzzer script.
You have a result when: all historical .repro scripts and a randomized
bypass soak stay green with the callback-pairing assumption deleted from the
code. Candidate, big — likely a multi-PR redesign; do not start without the
methodology skill's evidence bar in front of you.
Frontier 3 — The 10 MB class (performance headroom)
Why it matters: README claims "~1–2 MB files"; native-with-no-Electron is the differentiator, so headroom is a product claim, not vanity.
Edmund's asset: viewport-based lazy styling, the idle drain, incremental
reparse (pendingEdit window), PerfHarnessTests.
First three steps:
- Extend
PerfHarnessTestswith 5/10 MB fixtures (makeLargeMarkdown). - Profile the block-parse and restyle hot paths (
Log.measuresingle-line durations are already in place). - Set explicit latency budgets for open, first-paint, and per-keystroke restyle at 10 MB.
You have a result when: open + steady-state typing latency on a 10 MB file meets a stated budget, measured by the harness (not hand-timed).
Frontier 4 — A native extensions API
Why SOTA fails: Obsidian/VS Code plugin ecosystems are Electron; there is no strong precedent for a native, safe, fast extension surface for live-preview Markdown on macOS. ROADMAP v1.0.0 lists "Extensions API, documentations, primitive marketplace" and flags Advanced Syntax Highlighting / Advanced Math as "official extension" candidates.
Edmund's asset: the custom-parser seam
(Parsing/SyntaxHighlighter+CustomParsers.swift), the BlockKind/styleBlock
architecture, and already-modular opt-in syntax (math, Obsidian syntax).
First three steps:
- Catalog which existing features could be re-implemented as extensions (dogfood: callouts? highlight? wikilinks?) — this defines the real extension points.
- Define the minimal seam: block parser? inline parser? theme hook? Draw the line at what the custom-parser architecture already supports.
- Spike one official extension behind a flag and measure restyle cost vs the built-in.
You have a result when: one built-in syntax feature runs as an extension with no measurable restyle regression against the built-in baseline.
Frontier 5 — Accessibility / RTL / localization as a differentiator
Why it matters: native apps can excel where Electron editors are weak; ROADMAP v1.x lists Localization, RTL, Accessibility. Locale-aware content width already ships as precedent that the pipeline can be locale-sensitive.
Edmund's asset: the attribute-only pipeline (structure is in the string, not in inserted characters), the existing locale-aware content-width path.
First three steps:
- VoiceOver audit of
EditorTextView's custom drawing — does the accessibility tree expose headings/lists/callouts, given they're drawn as decorations? - Test RTL behavior of the attribute-only styling on a right-to-left document.
- Scriptable a11y check (structure read-out) as a regression guard.
You have a result when: a scripted VoiceOver audit reads document structure (headings, list items, callouts) correctly on a mixed document.
How to start one
- Predict numbers first (edmund-research-methodology §2) — every milestone above is a number, not a vibe.
- Instrument to make the current failure/limit cheap to measure.
- Prototype behind a flag; measure predicted vs observed.
- Route changes through edmund-change-control (branch, tests, no auto-push).
- When the first milestone lands, the item graduates to
misc/backlog.mdordocs/ROADMAP.mdand stops being a frontier.
What NOT to start (no current asset)
- iOS / iPadOS port — explicitly TBD in ROADMAP; no shared UI layer today.
- Collaborative / real-time editing — zero repo support (no CRDT, no sync,
single
NSDocumentmodel). Would be a new product, not a frontier of this one. - Anything that requires breaking an invariant (storage == rawSource; TextKit 2 only) to work — that's not a frontier, it's a rewrite (edmund-architecture-contract).
When NOT to use this skill
- Running an accepted investigation (a known bug) → edmund-caret-integrity-campaign / edmund-debugging-playbook.
- The method of turning a hunch into proof → edmund-research-methodology.
- Whether a public claim is allowed yet → edmund-external-positioning.
- Shipping the change → edmund-change-control.
Provenance and maintenance
Verified 2026-07-05. Assets exist; outcomes are unproven by definition — never quote a milestone here as achieved.
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
ls Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift
grep -n 'Extensions API\|RTL\|Localization\|iPadOS' docs/ROADMAP.md
grep -rn 'func makeLargeMarkdown' Tests/EdmundTests/TestHelpers.swift
When an item's milestone lands, move it to ROADMAP/backlog and delete it here — a frontier list that keeps solved problems is lying.
.agents/skills/edmund-validation-and-qa/SKILL.md
npx skills add I7T5/Edmund --skill edmund-validation-and-qa -g -y
SKILL.md
Frontmatter
{
"name": "edmund-validation-and-qa",
"description": "What counts as EVIDENCE in the Edmund Markdown editor, and how to add tests. Load when writing or adjusting tests, deciding what proof a fix needs before it can ship, judging whether a change is actually \"verified\", interpreting the test suite, or adding a golden\/regression check. Covers the evidence hierarchy (headless is necessary but not sufficient for live-input bugs), the test-suite map, the Swift Testing helpers, how to add a test, the golden inventory, visual QA, and performance evidence. Not the gating rules themselves (edmund-change-control) or how to run the live repro (edmund-live-repro-and-diagnostics)."
}
Edmund validation & QA
The discipline of proof. The central lesson: a green headless test is necessary but not sufficient for the bug classes that cost the most time here. Know which evidence each change actually requires.
Framework: Swift Testing (import Testing, @Test, #expect, #require)
— NOT XCTest. Verified 2026-07-05: 810 @Test cases across Tests/EdmundTests/.
1. The evidence hierarchy
Ordered weakest → strongest. The rule at the bottom names which class each change type requires.
| Class | What it proves | Blind spot |
|---|---|---|
(a) Headless unit test (makeEditor()) |
model/parse/style/undo logic | Cannot exercise deferred AppKit machinery — runs it synchronously |
(b) Windowed unit test (real NSWindow + NSScrollView, real deleteBackward) |
+ layout, viewport, first responder | still not real event-loop pacing / IME / drag |
| (c) Frozen live ReproScript repro (exact script + document) | the live input/timing mechanism | needs a debug build + ~1 min/run |
(d) Soak script green across 4–5 cycles, byte-identical final rawLen |
armed-state + determinism | slow |
| (e) Screencapture pixel measurement | anything that DRAWS | manual |
The trap that defines this skill: the delete-drift round-6 regression test passes with AND without the fix — the harness runs the queued selection fixup synchronously, so headless can't see the bug. For caret/IME/drag/viewport-timing bugs, class (a) is not evidence of a fix; you need (c)+(d).
Required evidence by change type (gates enforced in edmund-change-control):
| Change | Requires |
|---|---|
| Pure logic (parse/style/model) | (a) |
| Viewport/lazy-layout | (b), often (e) |
| Anything that draws | (a where testable) + (e) in light AND dark |
| Edit-pipeline / selection / IME / undo | (a) to lock the headless contract + (c) frozen repro + (d) soak |
| Release | see edmund-release-and-operate |
2. Test-suite anatomy
Run: swift test (full suite; ARCHITECTURE cites ~750+ tests ≈10s — 810 @Test
cases as of 2026-07-05). One suite: swift test --filter <Suite>. swift test
also runs automatically as a Stop hook (.Codex/settings.json) at the end of
any turn touching code, so failures surface before you commit.
Map of Tests/EdmundTests/ (what the files actually cover):
- Parsing:
BlockParserTests,SyntaxHighlighterTests,IncrementalParseFuzzTests,PendingEditTests,EscapeRenderingTests,HTMLTagRenderingTests,EmojiRenderingTests,LineEndingTests. - Rendering / styling:
BlockStylingTests,CalloutRenderingTests,CalloutTests,CommentRenderingTests,NestedBlockStylingTests,InlineStylingTests,MathRenderingTests,ImageRenderingTests,CodeHighlighterTests,FootnoteTests,WikiLinkTests,TableAlignmentTests,RenderingRegressionTests. - Editor behavior:
EditorIndentationTests,ListContinuationTests,BlockquoteContinuationTests,BlockquoteDeletionTests,FormattingTests,ActiveBulletMarkerTests,NewListItemAlignmentTests. - Edit-pipeline integrity (the costly class):
BypassedEditSyncTests,MarkedTextDesyncTests,WrappedParagraphCaretTests,EditorDiagnosticsTests,UnmatchedDebugTests,InternationalInputTests. - Viewport / layout / undo:
LazyRenderingTests,ScrollStabilityTests,HeightStabilityTests,TypewriterCenteringTests,UndoRedoViewportTests,EditorUndoTests,RecomposeTests,RecomposeEquivalenceTests. - Export / read mode:
HTMLRendererTests,DocumentHTMLTests,ReadModeWebViewTests,HTMLThemeTests,EditorThemeTests,ViewModeTests,ContentWidthTests. - Infra / harness:
LogTests,CrashReporterTests,PerfHarnessTests,StatusBarPrefsTests,FileIntegrationTests,EditorDocumentTests,TestHelpers.swift._RenderDump.swift/_RenderEdit.swiftare local dev tools (gitignored intent) that dump Read-mode HTML / edit output fortmp/sample.md— not part of the assertion suite.
Two invariant-guarding patterns worth copying:
- Recompose equivalence (
RecomposeEquivalenceTests, helperassertMatchesFullRecomposeOracle): an incremental restyle must produce the same result as a full recompose. This is the safety net for the lazy / incremental styling paths. - Incremental-parse fuzz (
IncrementalParseFuzzTests): random edits must keep the parser's window-reparse consistent with a full reparse.
3. Test helpers (TestHelpers.swift)
@MainActor helpers you build on (verified exports):
| Helper | Use |
|---|---|
makeEditor() |
an EditorTextView on the real TK2 chain (mirrors Document.makeWindowControllers) |
ensureFullLayout(...) |
force layout so geometry is real, not estimated |
type(...), paste(...), pressEnter(), pressBackspace() |
drive edits |
activateBlock(...) |
move the caret / active block |
displayText(...), attrs(...), font(...), fgColor(...) |
inspect styled output |
isHidden / isInvisible / isDimmed |
assert delimiter hiding |
blockDecoration(...), textBlockDifference(...) |
inspect decorations / catch TK1-reverting table attrs |
expectedFullComposition(...), drainAllStyling(), assertMatchesFullRecomposeOracle(...) |
equivalence-oracle checks |
makeLargeMarkdown(...), sentence(...) |
build big fixtures for perf/viewport |
styleBlock(_:cursorPosition:) is a method on EditorTextView
(Rendering/EditorTextView+Rendering.swift), called from tests — it renders one
block to an attributed string.
4. How to add a test
Skeleton (Swift Testing, @MainActor because the editor is main-thread):
import Testing
import AppKit
@testable import EdmundCore
@MainActor
@Test func deletingAtCalloutBottomKeepsInvariant() {
let editor = makeEditor()
editor.loadRawSource("> [!note]\n> body\n")
drainAllStyling()
// ... drive the edit ...
#expect(editor.rawSource == editor.string) // storage == rawSource invariant
}
Rules:
- Every bug fix ships with a test even if it can't discriminate a live-only
mechanism — it still locks the headless contract so a future refactor
can't silently re-break the model half. For the live half, keep the frozen
.reproscript alongside (edmund-live-repro-and-diagnostics). - Assert the invariant where you can (
rawSource == string), not just the surface symptom — that's whatBypassedEditSyncTests/MarkedTextDesyncTestsdo. - Name the test for the behavior/bug, put edit-pipeline repros next to their
siblings (the
*Desync*/*Bypassed*/*CaretTestsfamilies). - New drawing behavior additionally needs a screencapture check (§6).
5. Golden / certified inventory
RenderingRegressionTests+RecomposeEquivalenceTestsare the closest thing to golden checks — they pin styled output and incremental-vs-full equivalence.test-files/*.md(callout.md,decorations.md,math.md,menu.md,test.md) are the user's manual test corpus — hand-testing fodder. Do not rewrite them to fit an automated test; build your own fixtures (helpers in §3, or a scratch dir).misc/bug-repros/holds field evidence (.movscreen recordings +.logtraces) for open bugs — reference these when reproducing, don't delete them.
6. Visual QA
Anything that draws is verified by screencapture pixel measurement, in
light AND dark mode (per misc/before-you-release.md), never by eyeballing
headless layout. Method + scripts: edmund-live-repro-and-diagnostics §5.
Headless layout tests (HeightStabilityTests, ScrollStabilityTests) check
geometry numbers but cannot confirm the pixels are right.
7. Flakiness & CI
- A
fix/flaky-math-testbranch exists in history — math rendering has shown timing flakiness; if a math test flakes, check that branch's approach before inventing a new one (verify:git log --oneline --all -- '*Math*'). - CI:
.github/workflows/ci.ymlonmacos-14, latest-stable Xcode, SPM cache keyed onPackage.resolved,concurrency: cancel-in-progress(private-repo macOS minutes bill 10×). CI runs the sameswift test.
8. Performance evidence
PerfHarnessTestsmeasures the hot paths;makeLargeMarkdownbuilds big fixtures. Use it (don't hand-time) for any perf claim.- The README claim "handles ~1–2MB files" and
fullLayoutMaxLength = 100_000UTF-16 (EditorTextView.swift:80) mark the boundary between the full-layout regime (≤100k, geometry is real) and the estimate regime (>100k, viewport glitches possible). A perf/viewport claim must state which regime it was measured in.
When NOT to use this skill
- The gate/branch/commit rules → edmund-change-control.
- Running the live repro or measuring pixels → edmund-live-repro-and-diagnostics.
- The caret-integrity investigation end to end → edmund-caret-integrity-campaign.
- Why a mechanism works → textkit2-appkit-reference / edmund-architecture-contract.
- Release verification → edmund-release-and-operate.
Provenance and maintenance
Verified 2026-07-05.
grep -rh '@Test' Tests/EdmundTests/*.swift | wc -l # ~810 cases
grep -c 'import Testing' Tests/EdmundTests/TestHelpers.swift # confirms Swift Testing, not XCTest
grep -oE 'func [a-zA-Z]+' Tests/EdmundTests/TestHelpers.swift # helper inventory (§3)
ls Tests/EdmundTests/ # suite map (§2)
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
If a helper in §3 no longer greps it was renamed; update §3 and any test
skeletons. Re-derive the suite map from ls if files are added/removed.
.agents/skills/textkit2-appkit-reference/SKILL.md
npx skills add I7T5/Edmund --skill textkit2-appkit-reference -g -y
SKILL.md
Frontmatter
{
"name": "textkit2-appkit-reference",
"description": "Domain-theory pack for the AppKit text system as it applies to the Edmund Markdown editor. Load when working on layout, selection, IME, undo, drag, or eventing behavior; when TextKit 2 or NSTextView does something surprising; or when terms like NSTextLayoutManager, layout fragment, marked text, queued selection fixup, responder chain, or sendEvent appear and you lack AppKit text-system background. Explains the mechanisms the invariants and gotchas are built on. Not the invariants themselves (edmund-architecture-contract), not a triage table (edmund-debugging-playbook), not the repro drivers (edmund-live-repro-and-diagnostics)."
}
TextKit 2 / AppKit reference (as it applies to Edmund)
The background a mid-level engineer or Sonnet-class model usually lacks. Each concept: brief theory, then where it bites in Edmund with a verified file pointer. This is not a textbook — it is only the parts that matter here.
Facts checked against source 2026-07-05. Items labeled (background) are general AppKit/TextKit behavior grounded in Apple's documentation, not directly grep-able in this repo.
1. The TextKit 2 object model (background + repo)
TextKit 2 replaced the TextKit 1 NSLayoutManager stack. The players:
NSTextContentStorage— owns the backing string + attributes (the model).NSTextLayoutManager— lays text out (the TK2 analogue of the old layout manager).NSTextLayoutFragment— one laid-out chunk (≈ a paragraph); has a real geometric frame only once laid out.NSTextElement/NSTextParagraph— the model-side elements fragments render.
Viewport-based layout is the headline difference: TK2 lays out only the content near the visible viewport, not the whole document. That is what makes big documents fast — and it is the root of most viewport pain (§2).
Where it bites: Edmund subclasses the fragment as DecoratedTextLayoutFragment
(EditorTextView+TextKit2.swift) to draw callout boxes, bars, and overlays.
2. Height ESTIMATES — the master cause of viewport glitches
A fragment that has not been laid out yet has an estimated height, not a real one; the total document height is the sum of real + estimated fragment heights. As layout reaches a fragment, its estimate is replaced by the true value and everything below shifts. Consequences: the scroller thumb jumps, and "scroll to offset Y" lands wrong because Y was computed from estimates. This is a widely documented TK2 limitation — even TextEdit shows it (background).
Where it bites / Edmund mitigations (verify names by grep; all in TextView/):
fullLayoutMaxLength = 100_000(EditorTextView.swift:80): documents ≤ 100k UTF-16 units are kept fully laid out (no estimate regime) by a coalesced next-run-loop settle.scheduleFullLayoutSettle/preservingViewportAnchor: the settle runs inside an anchor block so corrections never shift what is on screen.repairContentAboveOrigin(+LazyStyling.swift, logsrepairing content above origin): fixes the case where an edit near the top strands the first fragment at negative y (unreachable above the scroller top).centerViewportOnCaret: re-measures after its first scroll and corrects the residual estimate error.
Rule: never trust an off-screen fragment's y-coordinate without laying out
its span first. Deep write-up: docs/investigations/viewport-glitch-investigation.md.
3. The TextKit 1 fallback trap
An NSTextView can silently and permanently revert from TK2 to the legacy
TK1 stack. Two known triggers: accessing NSTextView.layoutManager (the
mere getter engages TK1), and storing NSTextBlock/NSTextTable
attributes. Once reverted, TK2 APIs still exist but do nothing useful, and the
whole editor misbehaves subtly.
Where it bites: Edmund ships a DEBUG tripwire — an observer on
NSTextView.willSwitchToNSLayoutManagerNotification that asserts if the switch
happens (EditorTextView.swift:273+, message "TextKit 1 fallback triggered").
Never add code that reads layoutManager or stores table attributes; draw
tables as decorations instead (EditorTextView+TableRendering.swift).
4. Attribute-only mutation semantics
Edmund renders by writing attributes onto the storage, never by inserting or deleting characters (the storage == rawSource invariant). Two consequences from the text system:
setAttributesdoes not re-measure geometry. After a restyle that changes a block's height or indent, you must callinvalidateLayout(for:)on its range or the fragment keeps a stale frame (empty bands / clipped lines).recomposeDirtyand the idle drain already do this; new paths must too.NSTextAttachmentis only honored on theU+FFFCobject-replacement character (background).rawSourcenever containsU+FFFC, so attachments can't be used — Edmund draws images/icons as overlays (§5) instead.
5. The custom drawing model (fragments, decorations, overlays)
DecoratedTextLayoutFragment draws two attribute families behind/over text:
.blockDecoration(paragraph-level): callout boxes, quote bars, table borders, thematic-break rules, code backgrounds. Fragments tile vertically, so a multi-line run renders as one continuous box. A box'sbottomPadgrows the last fragment's frame (TK2 omits trailing paragraph spacing from the fragment, so padding done otherwise would be dead space)..fragmentOverlay(character-level): an image or a stroked vector path drawn at a glyph's laid-out position — rendered math, list bullets/checkboxes, callout header icon, the custom-title callout icon (path). The anchor glyph is hidden (≈0.01 pt font + clear color) and.kernreserves the drawing's advance width.
The image-on-wrapping-fragment wedge: drawing an image overlay on a
multi-line (wrapping) fragment re-triggers a layout pass that collapses the
fragment to one line. Drawing a shape/path does not. So the wrapping
callout title's icon is a stroked CGPath (parsed by SVGPath from vendored
Lucide geometry), never an image. Full saga:
docs/investigations/archives/callout-title-wrap-investigation.md. This constraint holds for any new
overlay that could share a line with wrapping text.
Hiding text = hiddenFont (≈0.01 pt) + clear foregroundColor. This is how
delimiters (**, `, [!note]) vanish without changing the string.
6. The queued selection fixup (the round-6 delete-drift mechanism)
When you mutate an NSTextView's storage, AppKit queues a private step,
-[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:], that repairs
the selection against the new character coordinates. Normally it fires promptly.
But if an edit bypasses the normal close-out (see §7), the fixup stays
queued and fires at the next endEditing — even an attribute-only
restyle — where it maps the now-stale selection against post-edit
coordinates and leaps the caret blocks away. It will move even a freshly set,
valid caret.
Where it bites: this is delete-drift round 6. The heal must set the caret
(from the pendingEdit hull) before the sync and re-assert it after
(EditorTextView+EditFlow.swift). Recognize a variant by: a suspicious
selection change arriving mid-recompose (up=Y in traces);
traceSelectionOrigin will log the call stack of whoever moved it.
Critical for testing: a headless test harness runs this deferred fixup synchronously, so this bug class cannot reproduce in a unit test — the round-6 regression test passes with and without the fix. Only the live in-process repro discriminates (edmund-live-repro-and-diagnostics).
7. The AppKit edit pipeline contract (and where AppKit breaks it)
Normal edit: shouldChangeText(in:replacementString:) → the view calls
replaceCharacters → didChangeText(). Edmund's didChangeText syncs
rawSource from storage and restyles the edited block(s).
AppKit does NOT always send didChangeText. A drag-move of selected text
whose drop lands on no valid target (e.g. released past the end of the
document) deletes the dragged range via shouldChangeText → replaceCharacters
and never calls didChangeText — silently freezing rawSource/blocks,
after which every edit drifts and autosave writes stale content (delete-drift
round 4). Edmund heals this: shouldChangeText schedules a next-run-loop
bypass check (scheduleBypassedEditSyncCheck, +EditFlow.swift); an
unconsumed storage pendingEdit by then means the close-out never came, and the
editor runs the sync itself (breadcrumb: healing storage edit that bypassed didChangeText).
Never build a sync path on the assumption that didChangeText follows every
edit.
The authentic key route (background + repo): keyDown →
interpretKeyEvents → insertText: / deleteBackward:. This is why the repro
driver synthesizes real NSEvents and pushes them through window.sendEvent(_:)
rather than calling insertText directly — shortcuts skip deleteBackward's
selection machinery, which is exactly where round 6 lived.
8. IME / marked text lifecycle
While an input method is composing (e.g. CJK, accents), the view holds
provisional "marked" text in storage; hasMarkedText() is true. During this
window storage == rawSource is transiently false, and didChangeText
defers syncing until the composition commits.
The cascade: any styling path that runs
beginEditing/setAttributes/invalidateLayout mid-composition can strand
the marked text in the input context. After that, didChangeText keeps bailing
on its own guard and the invariant stays broken — so every later edit drifts
the caret (the original delete-drift bug). Therefore every storage-touching
styling path must guard !hasMarkedText() — including async ones scheduled
before composition began (the caret-move restyle in +SelectionTracking).
becomeFirstResponder resyncs from storage as a catch-all. Full write-up:
docs/investigations/delete-drift-investigation.md.
9. Responder chain & nil-target actions (background + repo)
The responder chain is AppKit's search order for who handles an action.
Menu items and toolbar buttons with a nil target send their action up the
chain until something responds. Edmund's Format menu (FormatMenu.swift) is a
declarative command table whose items use nil targets and route to the focused
EditorTextView's @objc format… actions — the same wiring as undo/redo. The
first responder is normally the focused EditorTextView.
10. NSWindow.sendEvent — the pre-toolbar event funnel
Every event a window receives passes through sendEvent(_:) before the
toolbar acts. This matters because with NSToolbar.allowsUserCustomization = true, the toolbar claims any secondary (right/control) click over the
toolbar — including a custom item view — for its own "Customize Toolbar…" menu,
downstream of view-level handlers (menu, rightMouseDown, gesture
recognizers all lose). Edmund's fix for the view-mode button: intercept in
DocumentWindow.sendEvent(_:), pop the menu when the click is inside the
button's bounds, and swallow it (return); other clicks fall through to
super. (Caveat: true fullscreen moves the toolbar to a separate window, so
this main-window hook wouldn't cover it.)
11. Drag sessions (background + repo)
- Text drag-move arming: AppKit only starts a text drag after a mouse-down hold (~400 ms); a CGEvent driver must hold before moving or the drag never arms.
- Drag-select autoscroll: dragging past the viewport edge autoscrolls.
- Reveal at nearest end: a selection taller than the viewport must be
revealed at its nearest end (Edmund's
scrollRangeToVisibleoverride) — always revealing the top fought the drag-select autoscroll and oscillated the viewport mid-drag.
12. swift-markdown walker model (brief)
Edmund parses with apple/swift-markdown (CommonMark/GFM) and walks the
resulting Document with two back-ends: a SpanCollector-style walk that
produces editor attributes, and HTMLRenderer that produces HTML for Read mode.
One parser, two outputs — so Edit and Read can't drift. Custom (non-CommonMark)
syntax — callouts, ==highlight==, wikilinks, comments, footnotes, math — is
handled by SyntaxHighlighter+CustomParsers.swift.
When NOT to use this skill
- The project's rules/invariants (what you must not do) → edmund-architecture-contract.
- Which symptom means which mechanism → edmund-debugging-playbook.
- Reproducing a live bug / reading traces → edmund-live-repro-and-diagnostics.
- The history of how these mechanisms were discovered → edmund-failure-archaeology.
- Running the caret-integrity campaign → edmund-caret-integrity-campaign.
Provenance and maintenance
Verified 2026-07-05. Re-verify the load-bearing identifiers:
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
grep -rn 'blockDecoration\|fragmentOverlay\|hiddenFont' Sources/EdmundCore/
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
Items marked (background) are AppKit/TextKit behavioral facts documented by
Apple and in docs/*-investigation.md, not directly observable by grep. If any
Edmund mitigation name above no longer greps, it was renamed — update this file
and docs/ARCHITECTURE.md §5/§8 together.
.claude/skills/edmund-architecture-contract/SKILL.md
npx skills add I7T5/Edmund --skill edmund-architecture-contract -g -y
SKILL.md
Frontmatter
{
"name": "edmund-architecture-contract",
"description": "The load-bearing design contract of the Edmund Markdown editor. Load BEFORE any non-trivial code change in this repo; when asking \"why is it built this way\"; before proposing a new mechanism, subsystem, or refactor; whenever you are tempted to insert\/strip display characters, use NSTextAttachment, touch NSTextView.layoutManager, store NSTextBlock\/NSTextTable attributes, or add a new overlay\/decoration; and before designing anything that syncs storage, selection, undo, or the viewport. Covers the two hard invariants (storage == rawSource; TextKit 2 only), the render pipeline, edit\/undo flow, the TextKit 2 drawing model, the read-mode contract, and the known weak points. Not for build\/run\/release mechanics, debugging triage, or live-repro drivers — see \"When NOT to use this skill\"."
}
Edmund architecture contract
Edmund is a native macOS Markdown editor with live preview: AppKit +
TextKit 2, SwiftPM, macOS 14+. Two targets (Package.swift):
| Target | Role |
|---|---|
EdmundCore |
Library: parsing, rendering, EditorTextView, all tests. Most work happens here. |
edmd |
Executable: NSDocument app shell, Settings (SwiftUI), menus. Note: edmd is the Mach-O binary name; the app is "Edmund". |
Project ambition (maintainer, 2026-07-05): product-first — "the CotEditor of Markdown editors". Bias toward polish of the editing experience over feature count. The hardest live problem class to date is delete-drift (caret/selection integrity, 6 investigation rounds); the costliest failures were delete-drift and undo/redo viewport drift. Every rule below traces to one of those scars.
Ground truth this file distills: docs/ARCHITECTURE.md (repo root).
Treat that doc as authoritative if the two ever disagree, and fix this skill.
Glossary (each term defined once)
- rawSource — the document's Markdown text, the single source of truth
(
EditorTextView.rawSource). - storage — the
NSTextStoragethe text view displays (EditorTextStorage,Sources/EdmundCore/TextView/EditorTextStorage.swift). - Block — one logical Markdown block (paragraph, heading, list run, code
fence, table, quote/callout run). Model:
Sources/EdmundCore/Model/Block.swift. - Active block — the block under the caret; it renders its raw markdown (delimiters visible/editable) while all others render styled.
- Recompose — restyling storage from
blocks(Sources/EdmundCore/TextView/EditorTextView+Composition.swift). - Fragment — an
NSTextLayoutFragment, TextKit 2's per-paragraph layout unit. Off-screen fragments have estimated heights until laid out. - Overlay — an image or stroked path drawn at a character's laid-out
position by the custom fragment class (see §4), replacing what
NSTextAttachmentwould do in TextKit 1. - Delete-drift — the bug class where
rawSource/storage/selection desync and every later edit lands the caret in the wrong place. Chronicle:docs/investigations/delete-drift-investigation.md. - IME composition — an input method's provisional "marked text"
(
hasMarkedText()), present in storage before the user commits it.
1. The two non-negotiable invariants
Break either and the editor misbehaves in subtle, delayed ways. Every design review starts here.
Invariant 1 — storage == rawSource (attribute-only rendering)
The displayed text storage is always character-identical to rawSource.
Rendering only ever adds/changes attributes. Delimiters (**, `,
[!note], …) are hidden, never stripped: hiddenFont (0.01pt system
font, Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:55) plus a
clear foregroundColor makes them invisible without touching the string.
Rationale. Identity mapping between display offsets and raw offsets means there is no offset-translation layer — caret math, selection, undo diffs, incremental reparse, and autosave all operate on one coordinate system.
Consequences you must respect:
| Consequence | Why |
|---|---|
No NSTextAttachment, ever |
TextKit 2 only honors attachments on U+FFFC (OBJECT REPLACEMENT CHARACTER), which rawSource never contains. Images, math, bullets, checkboxes, icons are drawn as overlays instead (§4). |
| No inserted display characters | A synthesized <br>, bullet glyph, or padding character would desync offsets. Use attributes (.kern, paragraph styles) or overlays. |
| Never mutate storage while IME is composing | During composition, storage holds marked text so the invariant is transiently false and didChangeText defers syncing. Styling that runs beginEditing/setAttributes/invalidateLayout mid-composition strands the marked text; the invariant then stays broken and every later edit drifts the caret. Every storage-touching styling path — including async ones scheduled before composition began — must guard !hasMarkedText(). |
The incident. The original delete-drift bug: an async restyle fired during
IME composition, stranded the marked text, didChangeText kept bailing on its
own guard, and the invariant stayed silently broken — caret drift on every
subsequent edit. becomeFirstResponder now resyncs from storage as a
catch-all. Full write-up: docs/investigations/delete-drift-investigation.md.
Invariant 2 — TextKit 2 only
Never touch NSTextView.layoutManager (the TextKit 1 NSLayoutManager
accessor) and never store NSTextBlock/NSTextTable attributes. Either one
silently and permanently reverts the view to TextKit 1 — no error, no log,
just different (and wrong-for-us) layout from then on.
Rationale. All custom drawing rides NSTextLayoutFragment subclassing
(§4), and viewport-based layout (only on-screen content laid out) is what
makes large documents fast. Both are TextKit 2 facilities; a TK1 fallback
kills them.
The tripwire. DEBUG builds observe
NSTextView.willSwitchToNSLayoutManagerNotification and assertionFailure
if the fallback ever triggers —
textKit1FallbackTripwire(_:),
Sources/EdmundCore/TextView/EditorTextView.swift:272-303. If you see that
assertion, some code path you touched used a TK1 API or attribute. Find it;
do not suppress the assert.
Corollary: Edit-mode tables cannot use NSTextTable. Alignment is done by
distributing slack via .kern on hidden pipe glyphs —
Sources/EdmundCore/Rendering/EditorTextView+TableRendering.swift.
2. Render pipeline
rawSource ──BlockParser──▶ [Block] ──styleBlock per block──▶ attributed runs in storage
│
└─ SyntaxHighlighter (swift-markdown walker
+ custom parsers: callouts, ==highlight==,
wikilinks, comments, footnotes, math,
backslash escapes, inline HTML tags)
| Stage | Where |
|---|---|
| Block splitting | Sources/EdmundCore/Parsing/BlockParser.swift — parse(_:previous:), parseWithDiff(...) |
| Span production | Sources/EdmundCore/Parsing/SyntaxHighlighter.swift + +Walker.swift / +WalkerInline.swift / +CustomParsers.swift |
| One-block render | styleBlock(_:cursorPosition:...), Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift:151; per-feature extensions in Sources/EdmundCore/Rendering/ (Callout, Code, Image, List, ListMarker, Math, Table, WikiLinks) |
| Orchestration | Sources/EdmundCore/TextView/EditorTextView+Composition.swift |
Recompose entry points (pick the narrowest that works — a full recompose
resets every fragment height to an estimate, see §6):
| Function | Scope | Used for |
|---|---|---|
recompose(cursorInRaw:) |
Whole document | Load, indent — never for undo (§3) |
recomposeDirty(_:cursorInRaw:) |
A set of block indices, in place | The workhorse; attribute-only |
recomposeIncremental(cursorInRaw:...) |
The block(s) the caret moved between | Most cursor moves |
recomposeReplacing(oldRange:with:...) |
One contiguous text span | Undo/redo restore |
Lazy styling (Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift):
a large dirty set styles only the viewport synchronously; the rest is finished
by the idle drain (time-budgeted main-thread slices) and scroll
promotion (style blocks as they enter the viewport).
Gotcha: attribute-only changes do not re-measure geometry in TextKit 2.
If a restyle changed a block's height/indent, call invalidateLayout(for:)
on its range or the fragment keeps a stale frame. recomposeDirty and the
idle drain already do this; any new path must too.
3. Edit flow & undo
Normal edit: shouldChangeText (records a coalesced undo snapshot) →
NSTextView mutates storage → didChangeText syncs rawSource and restyles
the edited block(s) (Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift,
+Composition.swift). Edits capture a pendingEdit on EditorTextStorage
and reparse a window, not the whole document.
Undo/redo is custom (Sources/EdmundCore/TextView/EditorTextView+Undo.swift):
stacks of rawSource snapshots, bypassing NSTextView's built-in undo.
Restoring diffs the snapshot against current text (textDiff(old:new:),
single contiguous span) and applies it with the range-bounded
recomposeReplacing — never a full recompose, because a full recompose
resets every fragment to a TextKit 2 height estimate and the follow-up scroll
lands wrong (this was the undo/redo viewport-drift failure). The changed text
drives the viewport: hold if any of it is on-screen, else center it.
AppKit does NOT pair every storage mutation with didChangeText. Proven
incident (delete-drift round 4): a drag-move of selected text dropped on no
valid target deletes the dragged range via shouldChangeText →
replaceCharacters and never calls didChangeText — silently freezing
rawSource/blocks; every later edit drifts the caret and autosave writes
stale text. The heal: shouldChangeText schedules a next-run-loop
bypass check (scheduleBypassedEditSyncCheck, +EditFlow.swift) — a
pendingEdit still unconsumed by then means the closing didChangeText
never came, and the editor runs the same sync itself. Breadcrumb in
~/.edmund/logs: healing storage edit that bypassed didChangeText.
Never build a sync path on the assumption that didChangeText follows
every edit.
Round 6 corollary: a bypassed edit also leaves TextKit 2's private selection
fixup (_fixSelectionAfterChangeInCharacterRange:) queued; it fires at the
next endEditing — even an attribute-only restyle — and leaps the caret
blocks away, moving even a freshly set valid caret. The heal sets the caret
before the sync and re-asserts it after (+EditFlow.swift). This class
does not reproduce headless; see the routing in §8.
4. TextKit 2 drawing model
All custom visuals are drawn by DecoratedTextLayoutFragment (custom
NSTextLayoutFragment,
Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift:160), vended via
the layout-manager delegate. Two custom attribute keys (same file, lines
28/32):
| Attribute | Level | Draws | Rules |
|---|---|---|---|
.blockDecoration |
Paragraph | Callout boxes, quote bars, table borders, thematic-break rules, code backgrounds | Fragments tile vertically so a multi-line run reads as one continuous box/bar. A box's bottomPad grows the last fragment's own frame — TextKit 2 omits trailing paragraph spacing from the fragment, so padding done any other way is dead space. |
.fragmentOverlay |
Character | An image or stroked vector path at a character's laid-out position: rendered math, list bullets/checkboxes, callout header icon+name image, custom-title callout icon (path) | The anchor glyph is hidden (hiddenFont + clear color) and .kern reserves the drawing's advance width — the same trick the table renderer uses. |
The image-wedge constraint (open, not solved). Drawing an image overlay
on a multi-line (wrapping) fragment re-triggers a layout pass that wedges
the fragment to one line. Drawing a shape (stroked CGPath) does not.
That is why the wrapping callout custom-title icon is a stroked path parsed
from vendored Lucide geometry, never an image. Any new overlay that could
share a line with wrapping text must be a shape, not an image. Full saga:
docs/investigations/archives/callout-title-wrap-investigation.md.
5. Read mode contract
Read mode is a separate WKWebView, not an editor styling mode
(Sources/EdmundCore/Export/). The contract: one parser, two back-ends —
the same swift-markdown Document the editor parses is walked by
SyntaxHighlighter.SpanCollector (→ editor attributes) and by HTMLRenderer
(→ HTML), themed from the same EditorTheme via HTMLTheme, so the two
renderings cannot drift. When adding a feature, implement it in both
back-ends or document the divergence.
Hard properties of the web view (keep them):
- JavaScript disabled; every asset inlined (math as high-DPI PNG data
URIs — SwiftMath has no SVG path; icons as inline Lucide SVG) so it needs
no file/network reach. Remote images off by default
(
Sources/EdmundCore/Export/ReadRenderOptions.swift). - Private URL schemes route navigation without JS:
x-edmund-wiki:/x-edmund-link:(Sources/EdmundCore/Export/HTMLRenderer.swift:26,31). - Inline HTML: only the whitelist
SyntaxHighlighter.htmlFormatTags(u/kbd/mark/sub/sup,Sources/EdmundCore/Parsing/SyntaxHighlighter.swift:22) renders in either mode; everything else stays escaped/color-only. A real<br>break would need to mutate storage — forbidden by Invariant 1. - Export/Print run the same HTML through
WKWebView.printOperation(Sources/EdmundCore/Export/MarkdownPrinter.swift) for vector text.
6. Known weak points (open as of 2026-07-05)
State these plainly when designing near them; none is solved.
- TextKit 2 height estimates are the root of most viewport glitches: an
off-screen fragment's frame (and the total document height) is an estimate
until layout reaches it — scroller jumps, scroll-to-target lands wrong (a
documented TK2 limitation; TextEdit shows it too). Mitigations in place,
not cures: documents ≤
fullLayoutMaxLength(100k UTF-16,Sources/EdmundCore/TextView/EditorTextView.swift:80) are kept fully laid out byscheduleFullLayoutSettle()wrapped inpreservingViewportAnchor(+LazyStyling.swift:121,+TypewriterScroll.swift:22);repairContentAboveOrigin()(+LazyStyling.swift:151) fixes content stranded above y=0;centerViewportOnCaretre-measures after its first scroll. Never trust an off-screen fragment's y-coordinate without laying out the span first. - The image-wedge constraint (§4) applies to every new overlay.
- Open bugs (
misc/backlog.md): callout at end-of-file renders an extra un-prefixed line in the callout color (live incremental-restyle path, not static rendering); footnotes don't render in either mode; attached images create blank space below; math doesn't render in read mode (and has wrong padding in edit mode); delete caret drift and viewport-estimate glitches remain on the ongoing list. - Crash reporter endpoint is a placeholder:
CrashReporter.reportingEndpointishttps://REPLACE-ME.invalid/crash(Sources/EdmundCore/Diagnostics/CrashReporter.swift:27) and the Settings ▸ Advanced toggle is commented out (Sources/edmd/Settings/AdvancedSettingsView.swift). Do not treat crash uploading as live.
7. Before you design something new — checklist
Run this before proposing any new mechanism, subsystem, or refactor:
- Invariant 1: does it insert/strip characters, use
NSTextAttachment, or mutate storage outside the shouldChangeText→didChangeText path (or during IME composition)? If yes, redesign as attributes/overlays. - Invariant 2: does it touch
NSTextView.layoutManageror storeNSTextBlock/NSTextTable? If yes, stop. - Sync assumptions: does it assume
didChangeTextfollows every mutation, or that a set caret stays put across the nextendEditing? Both assumptions are proven false (§3). - Geometry: does it read an off-screen fragment frame, or restyle
without
invalidateLayout(for:)when height changed? (§2, §6.) - Both back-ends: does a rendering feature cover Edit and Read (§5)?
- Prior art: check ARCHITECTURE.md §14 — especially nodes-app/swift-markdown-engine, an independent AppKit+TextKit 2 live-preview engine solving the same problems — before inventing a new mechanism for an editing-experience problem.
- Weak points (§6): does the design lean on anything listed there? Label it as such; unproven mitigations are "open/candidate", never "fixed".
- Verification plan: unit test if headless can repro; otherwise plan
a live repro (ReproScript) — do not ship a caret/IME/viewport fix on
reasoning alone. Visual claims are measured from
screencapturepixels, not eyeballed.
Process rules live in sibling skills, but never contradict them here: branch
per fix off main; never auto-push/PR/merge; swift test green + visual
verification before commit; never blanket pkill -x edmd (the maintainer's
daily-driver app shares the binary name — pgrep and kill only your own
PID); never request macOS Computer Access permissions.
When NOT to use this skill
| You need… | Use instead |
|---|---|
| Whether/how to gate a change, commit discipline, scope control | edmund-change-control |
| A symptom → cause triage path for a bug you're seeing | edmund-debugging-playbook |
| The blow-by-blow history of a past investigation | edmund-failure-archaeology |
| TextKit 2 / AppKit theory beyond Edmund's specific contract | textkit2-appkit-reference |
| Launch flags, debug bundles, defaults keys | edmund-config-and-flags |
| Build, stale-binary cures, screencapture mechanics, environment setup | edmund-build-and-env |
| Cutting a release, Sparkle/appcast/CI | edmund-release-and-operate |
| Driving a live repro (ReproScript, CGEvent, log tracing) | edmund-live-repro-and-diagnostics |
| Test-writing patterns, QA passes | edmund-validation-and-qa |
| Docs style, ARCHITECTURE.md upkeep | edmund-docs-and-writing |
| Positioning, comparisons, marketing claims | edmund-external-positioning |
| The caret/selection-integrity campaign specifically | edmund-caret-integrity-campaign |
| How to investigate an unknown (method, not facts) | edmund-research-methodology / edmund-research-frontier |
Provenance and maintenance
Facts verified against the repo on 2026-07-05. If a grep below stops matching, the fact drifted — update this file and cite the new location.
# Invariant 2 tripwire still present
grep -n "textKit1FallbackTripwire" Sources/EdmundCore/TextView/EditorTextView.swift
# Recompose entry points
grep -n "func recompose" Sources/EdmundCore/TextView/EditorTextView+Composition.swift
# Bypass heal
grep -n "scheduleBypassedEditSyncCheck" Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
# Custom draw attributes + fragment class
grep -n "blockDecoration\|fragmentOverlay\|class DecoratedTextLayoutFragment" Sources/EdmundCore/TextView/EditorTextView+TextKit2.swift
# hiddenFont hiding trick
grep -n "hiddenFont" Sources/EdmundCore/Rendering/EditorTextView+Rendering.swift
# Viewport mitigations
grep -n "fullLayoutMaxLength" Sources/EdmundCore/TextView/EditorTextView.swift
grep -n "scheduleFullLayoutSettle\|repairContentAboveOrigin" Sources/EdmundCore/TextView/EditorTextView+LazyStyling.swift
# Read-mode schemes + HTML whitelist
grep -n "wikiScheme\|linkScheme" Sources/EdmundCore/Export/HTMLRenderer.swift
grep -n "htmlFormatTags" Sources/EdmundCore/Parsing/SyntaxHighlighter.swift
# Crash-reporter placeholder (delete §6.4 once this is a real URL)
grep -n "REPLACE-ME.invalid" Sources/EdmundCore/Diagnostics/CrashReporter.swift
# Open-bug list
sed -n '/^Bugs/,/^UI\/UX/p' misc/backlog.md
.claude/skills/edmund-build-and-env/SKILL.md
npx skills add I7T5/Edmund --skill edmund-build-and-env -g -y
SKILL.md
Frontmatter
{
"name": "edmund-build-and-env",
"description": "Build, environment, and toolchain runbook for the Edmund repo (native macOS Markdown editor; AppKit + TextKit 2, SwiftPM). Load this skill when: setting up the environment from scratch (fresh clone, new machine, CI mirror); a build fails or a \"successful\" build behaves stale (change \"doesn't take\", old code runs, Build complete! but nothing changed); the app crashes on launch or the instant it renders LaTeX; you need to construct the debug bundle (EdmundDbg.app) for live runs; or BEFORE trusting any binary you just built for a visual check or repro run. Covers swift build\/test, build-app.sh anatomy (codesign sealing order, SwiftMath bundle placement), the stale-build disease and its detection, safe launch\/kill hygiene around the user's live instance, and the CI environment."
}
Edmund — build & environment runbook
All paths relative to the repo root. Facts date-stamped 2026-07-05 are volatile — re-verify per the last section.
When NOT to use this skill
| You actually need | Go to |
|---|---|
| The storage==rawSource / TextKit-2-only invariants, render pipeline | edmund-architecture-contract |
| Branch/commit/PR rules, what you may touch | edmund-change-control |
| Diagnosing a bug (not the build) | edmund-debugging-playbook |
| ReproScript / CGEvent live-repro driving | edmund-live-repro-and-diagnostics |
| Launch flags, defaults keys, settings | edmund-config-and-flags |
| Cutting a release, DMG, Sparkle appcast | edmund-release-and-operate |
| Screencapture verification method, test policy | edmund-validation-and-qa |
1. Environment from scratch
Requirements (verified 2026-07-05):
| Tool | Version | Why | Check |
|---|---|---|---|
| macOS | 14+ | Package.swift platforms .macOS(.v14) |
sw_vers |
| Xcode | 16+ (full Xcode, not just CLT) | swift-tools-version: 6.0; build-app.sh needs actool |
swift --version (local: Swift 6.0.3) |
| gh CLI | any recent | releases, PR ops | gh --version |
| Node | ≥20 | releases only | node --version |
| create-dmg | npm package | releases only | npm install --global create-dmg |
create-dmg trap: install via npm, NOT Homebrew. brew install create-dmg
is a different tool with an incompatible CLI. Not needed for dev work —
only for cutting releases (see edmund-release-and-operate).
Dependencies are fetched by SPM on first build — nothing to install by hand
(verified against Package.swift / Package.resolved, 2026-07-05):
swift-markdown≥0.5.0 (CommonMark/GFM parsing; pullsswift-cmarktransitively)SwiftMath≥1.7.0 (LaTeX rendering — its resource bundle is a launch-crash landmine, §3)Sparkle≥2.6.0 (auto-update — its framework is a dyld-abort landmine, §4)
Two SPM targets: EdmundCore (library + all tests; most work happens here)
and edmd (the app shell executable). The binary is named edmd even
though the app presents as "Edmund" — deliberate, see the comment in
Package.swift.
2. Core commands
swift build # debug build of both targets
swift test # full suite: ~750+ tests, ~10s (2026-07-05)
swift test --filter Callout # one suite
./scripts/build-app.sh # release build → build/Edmund.app
swift test also runs automatically as a Stop hook after code-touching turns.
3. What build-app.sh actually does (and why the order matters)
scripts/build-app.sh → build/Edmund.app. Steps, in order:
swift build -c release- Assemble
build/Edmund.app/Contents/{MacOS,Resources}; copy.build/release/edmd,Info.plist,Resources/AppIcon.icns. - Compile
Resources/Assets.xcassetswithactool(falls back to/Applications/Xcode.app/.../actoolif xcode-select points at the CLT). - Embed
Sparkle.frameworkintoContents/Frameworks/(found under.build/; SwiftPM links Sparkle but never copies the framework — without it the updater crashes on first check) andinstall_name_tool -add_rpath "@executable_path/../Frameworks"so@rpathresolves post-install. - Codesign inside-out: Sparkle.framework first (nested XPC helpers must
be signed before macOS will launch them), then the whole
.app(ad-hoc,--deep, identifiercom.i7t5.edmd). Sealing the bundle — not just the binary — is what Sparkle's update validator requires. - Only after sealing: copy
.build/release/*.bundle(SwiftMath's math fonts) into the.approot.
Why step 6 is last and at the root — two constraints collide:
codesignrefuses to seal a bundle with any extra item at the.approot ("unsealed contents present in the bundle root"), so the seal must happen while the root holds onlyContents/.- SwiftMath's generated
Bundle.moduleaccessor hardcodesBundle.main.bundleURL— the.approot — with only a hardcoded absolute.buildpath as fallback. So the bundle must sit at the root.
Resolution: seal first, copy after. The one unsealed root item makes
codesign --verify and --strict complain, but Sparkle's actual check is
non-strict and tolerates it (verified end-to-end; details in
docs/ARCHITECTURE.md §8).
Missing SwiftMath bundle = instant crash the moment the app renders any
LaTeX. App launches fine, opens documents fine, dies on the first math
block. If you see that crash, check ls build/Edmund.app/*.bundle first.
4. Debug bundle fast path (EdmundDbg.app)
For live runs of a debug build, skip build-app.sh and hand-assemble
(from docs/dev-guides/live-repro-guide.md §4):
swift build
mkdir -p build/EdmundDbg.app/Contents/MacOS
cp Info.plist build/EdmundDbg.app/Contents/
cp .build/arm64-apple-macosx/debug/edmd build/EdmundDbg.app/Contents/MacOS/
cp -R .build/arm64-apple-macosx/debug/Sparkle.framework build/EdmundDbg.app/Contents/MacOS/
- Sparkle.framework must sit next to the binary — dyld aborts without it.
- A bare
.build/debug/edmdruns but never creates a window. It needs the bundle (Info.plist) around it. - Launch by direct exec of the bundle binary, never
open -a:
build/EdmundDbg.app/Contents/MacOS/edmd /path/to/test.md \
-ApplePersistenceIgnoreState YES &
LaunchServices (open -a) can silently run a stale cached/translocated
copy — you'd be executing last hour's code. Direct exec runs exactly the
binary you just copied. -ApplePersistenceIgnoreState YES stops state
restoration from reopening previous (possibly mutated) documents.
- Recreate the test document fresh before every run — autosave mutates it.
5. THE STALE BUILD DISEASE
The single most expensive trap in this repo: it has produced entire wrong debugging conclusions ("my fix doesn't work" when the fix was never in the binary).
Symptom: swift build prints Build complete! having compiled a changed
file but not relinked edmd. The app then runs old code. Release builds
(swift build -c release / build-app.sh) reuse stale objects too.
Detection — before trusting ANY binary you just built:
# 1. Grep for a LONG string literal unique to your new code:
strings .build/arm64-apple-macosx/debug/edmd | grep 'your long unique literal'
# 2. Hash before/after the build:
shasum .build/arm64-apple-macosx/debug/edmd
Literal length matters: string literals ≤15 bytes are stored inline in
the Mach-O on arm64 and never appear in strings output. A short probe
literal gives a false "stale" verdict. Use a long one (a distinctive log
message works well).
Cure:
swift package clean # first resort
rm -rf .build # visual change "doesn't take" → nuke it all
Never hand-delete .build/…/edmd.build/ — that corrupts SwiftPM's
output-file-map and wedges the target until a full clean anyway.
6. Running for visual checks — launch/kill hygiene
The user's daily-driver app has the same binary name (edmd). A blanket
pkill -x edmd kills their live session. Always, in order:
# 1. Who is running, and since when?
pgrep -lx edmd
ps -o lstart=,command= -p <pid>
# 2. Kill ONLY your own PID — or, if you launched the debug bundle:
pkill -f EdmundDbg
Other run gotchas:
open Edmund.appforegrounds a running instance instead of relaunching — you'll be looking at the old binary. Kill your instance first or direct-exec the binary.- Always pass
-ApplePersistenceIgnoreState YES(see §4). - After many rapid launch/kill cycles the window server can glitch (tiny
windows, broken state restoration):
rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedStateand relaunch. - Verification method (window-id screencapture, offscreen render fallback):
edmund-validation-and-qaanddocs/ARCHITECTURE.md§8.
7. CI environment
.github/workflows/ci.yml (verified 2026-07-05): runs swift test on
macos-14 with latest-stable Xcode (Swift 6.0 needs Xcode 16+), triggered
on PRs and pushes to main.
- SPM cache:
.buildis cached keyed onspm-v2-${{ runner.os }}-${{ hashFiles('Package.resolved') }}. Thev2token exists because the repo renamemd→Edmundchanged the checkout path and invalidated absolute paths baked into the cached module cache — bump the token to discard a poisoned cache. - Concurrency:
cancel-in-progress: trueper branch/PR — private-repo macOS minutes bill at 10x, so superseded commits' runs are cancelled. - Release pipeline (
release.yml, tag-triggered) is a separate beast:edmund-release-and-operate/docs/ARCHITECTURE.md§13.
8. Checklists
Fresh clone to green
-
sw_vers— macOS 14+;swift --version— Swift 6.x (Xcode 16+) -
git clone+cd Edmund -
swift build— SPM fetches swift-markdown, SwiftMath, Sparkle -
swift test— ~750+ tests green in ~10s - Read
docs/ARCHITECTURE.md(mandated by CLAUDE.md before non-trivial work) - Visual work planned?
./scripts/build-app.sh, confirmbuild/Edmund.appexists andls build/Edmund.app/*.bundleshows the SwiftMath bundle - Releases planned?
node --version≥20,npm install --global create-dmg
Before trusting any run
- Binary is fresh:
strings <binary> | grep '<long unique literal>'and/orshasumchanged since the edit (§5) -
pgrep -lx edmd— user's live instance identified; you will kill only your own PID (§6) - Launched by direct exec, not
open -a(§4) -
-ApplePersistenceIgnoreState YESpassed - Test document recreated fresh (autosave mutated the last one)
- Debug bundle: Sparkle.framework sits next to the binary
- Release bundle: SwiftMath
*.bundleat the.approot (or the first LaTeX render crashes)
Provenance and maintenance
Every claim above was read from the files below on 2026-07-05. Re-verify:
- Toolchain/deps:
cat Package.swift(tools-version, platforms, dep versions);grep identity Package.resolved - Build anatomy:
cat scripts/build-app.sh(step order, sealing comments) - Test count/time:
swift test 2>&1 | tail -3 - Debug bundle recipe:
docs/dev-guides/live-repro-guide.md§4 - Stale-build disease, launch gotchas:
docs/ARCHITECTURE.md§8 ("Stale release builds", "open Edmund.app", savedState) - CI facts:
cat .github/workflows/ci.yml(cache key comment, concurrency) - create-dmg quirks:
docs/ARCHITECTURE.md§8 ("create-dmg — npm only")
If a command here disagrees with those files, the files win — update this skill.
.claude/skills/edmund-caret-integrity-campaign/SKILL.md
npx skills add I7T5/Edmund --skill edmund-caret-integrity-campaign -g -y
SKILL.md
Frontmatter
{
"name": "edmund-caret-integrity-campaign",
"description": "The executable, decision-gated campaign for Edmund's hardest live problem: the delete-drift class (caret\/selection integrity in the live NSTextView \/ TextKit 2 \/ input-context layer). Load when the caret or selection lands in the wrong place after a delete\/type\/IME\/drag interaction, when text edits corrupt or desync, when autosave writes stale content, or when logs show ⚠︎LEN-MISMATCH or the \"healing storage edit that bypassed didChangeText\" breadcrumb. Runs round 7+ at the retiring maintainer's standard: classify, fence off six settled battles, capture evidence, build a deterministic repro, pick from a ranked solution menu with proof obligations, then validate and promote through change control. Not for viewport\/scroll drift (that's edmund-debugging-playbook → viewport docs) unless the caret itself moves."
}
Caret-integrity campaign (the delete-drift class)
Six rounds are settled; new rounds are expected. This class does not
reproduce in headless tests — the harness runs AppKit's deferred machinery
synchronously, so a green unit test proves nothing here. Success is measured by
PASS/FAIL grep and byte counts, never by eye or by reasoning.
Verified 2026-07-05 against docs/investigations/delete-drift-investigation.md (456 lines) and
the code. Read that doc fully before a deep dive.
QUICK CARD (the whole loop)
0. CLASSIFY grep logs for the 4 signatures. Not this class? → debugging-playbook.
1. FENCE check the 6 settled battles + guards still hold. Don't re-fight them.
2. EVIDENCE run with verbose diags; find FIRST bad line; walk BACKWARDS;
traceSelectionOrigin names who moved the caret; rebuild the doc.
3. REPRO ReproScript: needles not offsets; real events; replicate internal
call sequences verbatim. Freeze a script whose logsel/assertcaret
DISCRIMINATES broken vs current build. No repro → hypothesis wrong.
4. SOLVE rank candidates (missing guard < extend heal < reorder fixup <
structural). Each states what it must PROVE.
5. PROMOTE fix flips frozen repro to PASS same run → soak 4–5 cycles,
byte-identical rawLen → swift test green → add test (locks headless
contract even if non-discriminating) + keep .repro → update the
investigation doc → branch/commit via change-control. Never ship on
reasoning alone.
PHASE 0 — Classify: is it actually this class?
Grep ~/.edmund/logs/edmund-<date>.log (run the app with
-settings.general.diagnosticLogging YES -settings.advanced.verboseEditorDiagnostics YES):
| Signature | Meaning |
|---|---|
healing storage edit that bypassed didChangeText |
a bypass fired (round-4 class) |
persisting ⚠︎LEN-MISMATCH (not just transient between shouldChangeText→synced) |
storage/rawSource desync stuck |
selectionDidChange with up=Y at a surprising position |
selection moved mid-recompose (round-6 class) |
a shouldChangeText with no synced/SKIPPED/DEFERRED after it |
a bypassed didChangeText |
scripts/grep-trace.sh in edmund-live-repro-and-diagnostics surfaces all
four at once.
If none match and the symptom is scroll/viewport-shaped (jump, wrong
landing, can't-scroll-up) with the caret not leaping → this is the viewport
class, not caret integrity → edmund-debugging-playbook +
docs/investigations/viewport-glitch-investigation.md. Do not run this campaign on a viewport
bug.
PHASE 1 — Fence off the six settled battles
Confirm each shipped guard still holds before hypothesizing a new mechanism. The most likely round-7 shape is a new code path that lacks an existing guard, not a new mechanism.
| Round | Mechanism (settled) | The shipped guard — confirm it still covers your path |
|---|---|---|
| 1–3 | IME marked-text stranding: styling that runs beginEditing/setAttributes/invalidateLayout while hasMarkedText() strands the composition; didChangeText then bails forever and every edit drifts |
every storage-touching styling path guards !hasMarkedText() — including async ones scheduled before composition began (+SelectionTracking caret-move restyle). becomeFirstResponder resyncs as catch-all |
| 4 | Drag-move bypass: a drag-move whose drop has no valid target deletes via shouldChangeText→replaceCharacters with no didChangeText; rawSource/blocks freeze; autosave writes stale text |
scheduleBypassedEditSyncCheck (+EditFlow.swift): a next-run-loop check finds an unconsumed storage pendingEdit and runs the sync (breadcrumb above) |
| 5 | Heal leaped the caret: the round-4 heal ran against a stale selection and moved the caret | heal collapses/derives the caret from the pendingEdit hull before syncing |
| 6 | Queued selection fixup: TK2's _fixSelectionAfterChangeInCharacterRange stays queued after a bypass and fires at the next endEditing (even an attribute-only restyle), remapping the stale selection and leaping even a freshly set, valid caret |
heal sets the caret from the pendingEdit hull before the sync AND re-asserts it after (+EditFlow.swift) |
| 7 | Same queued fixup on the NORMAL edit path (not the heal): armed by a cross-block caret move (schedules the async caret-move restyle), the fixup fires during syncRawSourceFromDisplay→recomposeDirty's endEditing on an ordinary keystroke and leaps the caret to the block boundary; the normal path styles settingSelection=false and never re-asserts, so it persists |
syncRawSourceFromDisplay captures the pendingEdit-hull caret before consumePendingEdit, re-asserts it after recomposeDirty if the fixup moved it (+EditFlow.swift; breadcrumb re-asserting caret after fixup leap (normal path)) — fix not yet confirmed by a deterministic repro, live-verifiable via the breadcrumb |
Confirm the guards exist:
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
FENCED WRONG PATHS (each proven dead — do not retry)
- Fixing by nudging the caret at symptom time. The symptom is armed seconds-to-minutes earlier (round 6: drift at 22:13 armed at 22:11:57). You'd patch the wrong instant.
- Trusting a headless regression test. The round-6 test passes with and without the fix. Headless runs the fixup synchronously.
- Shipping on reasoning alone. Rounds 1–5 all came back. Round 6's first fix candidate "worked by reasoning" and failed in the repro within a minute.
- Assuming
didChangeTextpairs with every mutation. AppKit violates this (round 4). - Mutating storage mid-composition. That is the original bug (rounds 1–3).
- Expecting a scripted keystroke replay to arm round 7. Round 7 was replayed
faithfully from a reconstructed
t0(every keystroke + capped pauses through the whole drift window) and it did not produce the block-end leap. Programmatic caret moves (clickoff) and imprecise synthetic clicks (realclickoff— lands off-by-a-few, diverges, crashes) do not fully arm it. The arming needs real mouse clicks at real positions and probably the real session's two-document window switching (becomeFirstResponderresync is the prime suspect). Round-8 lead: two open docs + real clicks, or instrumentbecomeFirstResponder/the caret-move restyle to catch the next live occurrence. Compressed replays also coalesce a bypass with the next keystroke into onependingEdithull (never happens live — the heal runs first), producing off-by-one breadcrumb noise; don't trust it as a repro.
PHASE 2 — Capture evidence
- Launch with verbose diagnostics (debug bundle; file arg is
argv[1]):build/EdmundDbg.app/Contents/MacOS/edmd DOC.md \ -settings.general.diagnosticLogging YES \ -settings.advanced.verboseEditorDiagnostics YES \ -ApplePersistenceIgnoreState YES - Decode the trace fields:
sel/active/marked/up/undo/blocks/storLen/rawLen.up=Y= event arrived mid-recompose (suspicious). Healthy ordering:shouldChangeText→selectionDidChange (up=N)→synced; a transient⚠︎LEN-MISMATCHbetween those is normal, a persisting one is not. - Find the FIRST bad line, then walk BACKWARDS. The visible symptom is often the second half of a two-part mechanism.
traceSelectionOriginlogs the call stack of whoever moved the selection mid-recompose — this is what named_fixSelectionAfterChangein round 6. If the caret moves and you don't know who moved it, this answers it in one run.- Reconstruct the document. Wrapped-paragraph geometry and block kinds
matter — repro against a lookalike, never
"hello world".
Gate: you have a candidate trigger hypothesis + the first-bad-line timestamp.
Otherwise instrument (add a Log.shouldTrace breadcrumb — one call stack or
state dump beats ten speculative fixes) and wait for the next occurrence.
PHASE 3 — Build the deterministic repro
Use the in-process ReproScript driver (Sources/edmd/App/ReproScript.swift,
DEBUG only; full guide in edmund-live-repro-and-diagnostics). Commands:
sleep / caret / type / backspace / bypassdelete / assertcaret / logsel.
Worked example — the round-6 minimal repro (the first deterministic repro in six rounds):
sleep 2000
bypassdelete Sizemore,
sleep 800
logsel # broken build: 321 fixed build: 290
backspace 2
logsel
The deciding output was logsel 321 → 290 with the fix, every run, window
not even visible.
Rules that make repros survive editing:
- Needles, not offsets — offsets go stale the instant the script edits.
- Real events, not method shortcuts —
insertText("")skipsdeleteBackward's selection machinery, exactly where round 6 lived. - Replicate AppKit-internal call sequences verbatim —
bypassdeletedoesshouldChangeText→replaceCharacters, nodidChangeText, not an approximation. For a new internal path, pin its real sequence from atraceSelectionOriginstack first, then replay it (extend ReproScript ~10 lines).
Gate: a frozen script (exact commands + document) whose logsel /
assertcaret PASS/FAIL output discriminates the broken build from the
current one.
No repro after honest attempts? The hypothesis is wrong, or fidelity is too low — a mouse-only path (real drag-select/drag-move) needs the CGEvent driver (edmund-live-repro-and-diagnostics §4). Loop back to Phase 2.
PHASE 4 — Solution menu (ranked; each with a proof obligation)
Pick by the observation pattern. Cheaper is higher.
- Add a missing guard on an existing invariant (cheapest, most likely for a
new round). Guard inventory:
!hasMarkedText()on the offending styling path; bypass-check scheduling on a new mutation entry point; caret re-assertion after a restyle. Proof: the frozen repro flips to PASS and no other.reproregresses. Selects when: a specific new code path shows the round-1/4/6 signature that its siblings already guard. - Extend the heal to a new bypass source. Proof: first add a ReproScript
command that replays the new source's exact call sequence, show it
reproduces the freeze, then show the extended heal fixes it. Selects when:
trace shows a
shouldChangeTextwith no close-out from a path other than drag-move. - Intercept/reorder the queued fixup. Proof obligation: explain when
TK2 queues and fires
_fixSelectionAfterChangeand show the reorder does not fight AppKit's machinery (no oscillation, no double-move). Selects when:traceSelectionOriginnames the fixup and the caret is valid before it fires. Higher risk — you are stepping into private AppKit ordering. - Structural: make rawSource sync independent of
didChangeTextpairing (e.g. a storage-version counter that reconciles regardless of which callbacks fired). Biggest change; candidate, unproven — route through edmund-research-frontier (frontier item "caret integrity by construction"). Proof: all historical.reproscripts + a randomized bypass-fuzzer soak stay green with the callback-pairing assumption removed.
PHASE 5 — Validate and promote
- Fix candidate flips the frozen repro to PASS within the same run. (If it only "works by reasoning," it is not done — round history.)
- Soak (edmund-live-repro-and-diagnostics §3): one script, one run, 4–5
trigger cycles at different document positions with ordinary editing between,
assertcaretafter every predictable step. Green across all cycles and byte-identical finalrawLenacross repeated runs = deterministic. swift testgreen (also the Stop hook).- Add a regression test even if it can't discriminate the live mechanism —
it locks the headless contract (assert
rawSource == string; see theBypassedEditSyncTests/MarkedTextDesyncTestsfamily). Keep the.reproscript in the repo for the live half. - Update
docs/investigations/delete-drift-investigation.mdwith the new round (symptom → root cause → repro recipe → fix → status), anddocs/ARCHITECTURE.md§8 if an invariant changed — same PR. - Route through edmund-change-control: branch
fix/…offmain, small commits, never auto-push/PR/merge. (No screencapture unless the fix also draws.)
Never promote a fix that only "works by reasoning."
When NOT to use this skill
- Viewport/scroll drift where the caret itself does not leap →
edmund-debugging-playbook +
docs/investigations/viewport-glitch-investigation.md. - Just need the repro driver mechanics / trace decoding → edmund-live-repro-and-diagnostics.
- The AppKit theory behind the mechanisms → textkit2-appkit-reference.
- The historical record of prior rounds → edmund-failure-archaeology.
- The structural (round-∞) redesign as a research project → edmund-research-frontier + edmund-research-methodology.
Provenance and maintenance
Verified 2026-07-05 against docs/investigations/delete-drift-investigation.md,
Sources/edmd/App/ReproScript.swift, and
Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift.
grep -nE '^## Round' docs/investigations/delete-drift-investigation.md # round chronicle
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
grep -oiE '"(bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
# round-6 discriminating numbers (321 broken / 290 fixed):
grep -n '321\|290' docs/investigations/delete-drift-investigation.md
When round 7 lands, add its row to Phase 1 and its dead ends to the fenced list.
.claude/skills/edmund-change-control/SKILL.md
npx skills add I7T5/Edmund --skill edmund-change-control -g -y
SKILL.md
Frontmatter
{
"name": "edmund-change-control",
"description": "How changes are classified, gated, and reviewed in the Edmund repo. Load at the START of any task that will modify the repo; before committing, branching, or proposing a merge or release; when deciding whether a change needs a test, a screencapture visual check, or a live repro; when unsure what requires explicitly asking the maintainer (push, PR, merge, release, deleting uncommitted work, touching test-files\/, adding dependencies). Contains the non-negotiable rules with the historical incident behind each one."
}
Edmund change control
Last verified: 2026-07-05, against main @ fe8a1f5 (release 0.1.3).
This is the gatekeeping doc: what class of change you are making, which gate it must pass, and the rules that are never traded away. The rules exist because each was paid for — the incident column is not decoration.
0. Before you start (task setup)
[ ] Read docs/ARCHITECTURE.md before any non-trivial change (its own rule)
[ ] git status — note any uncommitted work; never clobber it
[ ] Not on main? Fine. On main? Branch NOW, before the first edit
[ ] Classify the change (§1) so you know the gate before you write code
[ ] Edit-pipeline / selection work? Plan the live repro FIRST — if you can't
reproduce the bug, you can't prove the fix (§3, last row)
1. Classify the change, apply the gate
Classify FIRST, before writing code. The class decides the verification bar.
| Class | Examples | Gate before commit |
|---|---|---|
| Docs-only | ARCHITECTURE.md, README, docs/*.md, comments | None beyond review. swift test still runs as a Stop hook; ignore no failures it surfaces. |
| Code (logic) change | Parser, block model, helpers, non-drawing refactor | swift test green. New behavior or bug fix → add a test that fails without the change. |
| Visually-drawing change | Anything in Rendering/, overlays, decorations, padding, fonts, layout fragments |
All of the above, PLUS build the app and screencapture the result (window-by-id method — see edmund-live-repro-and-diagnostics). Headless layout is not proof for anything that draws. |
| Edit-pipeline / selection behavior | +EditFlow, +Composition, +SelectionTracking, +Undo, caret, IME, drag, viewport timing |
All of the above, PLUS a live repro or soak script (-debug.reproScript, see edmund-caret-integrity-campaign and docs/dev-guides/live-repro-guide.md). Headless tests cannot exercise deferred AppKit machinery — the queued selection fixup, drag paths, IME. Rounds 1–5 of delete-drift shipped on tests + reasoning; all recurred. |
| Release | Version bump, tag, appcast | Run misc/before-you-release.md top to bottom, then misc/how-to-release.md. See edmund-release-and-operate. Never start a release without being asked. |
Notes on the gates:
swift test(~750+ tests, ~10s) also runs automatically as a Stop hook (.claude/settings.json:swift test 2>&1 | tail -5) at the end of every turn. That is a safety net, not the gate — run it yourself before committing so the failure is yours to see, not the hook's.- A change can be in multiple classes. Apply the union of gates. A caret fix that also moves a decoration needs test + screencapture + live repro.
- "Draws" is broad: padding, insets, colors, wrapping, fragment frames. If a human could see the diff, screenshot it.
Classification edge cases that have gone wrong before:
- "It's just an attribute change" is NOT automatically the code-logic class.
If the attributes change measured geometry (font size, paragraph spacing,
hidden-delimiter width), it draws AND it needs
invalidateLayout(for:)— see §3. - "It's just a restyle helper" that runs
beginEditing/setAttributeson storage is edit-pipeline class if it can fire during IME composition or after a bypassed edit. When in doubt, grep forhasMarkedTextguards on the sibling paths and match them. - A test-only change is docs-class for gating purposes (nothing to screenshot), but the Stop hook still must pass — a broken test is a broken commit.
- Release-adjacent edits (
Info.plistversions,CHANGELOG.md,appcast.xml,scripts/release.sh,.github/workflows/) are release class even when tiny. The v0.1.0→0.1.1 Sparkle failure came from the build script's signing step, not from app code.
2. Git discipline
From CLAUDE.md + ARCHITECTURE §12 + the repo's own history (git branch -a).
- Branch off
mainfor every fix. Never commit straight tomain. One feature/fix per branch. - Branch prefixes actually in use (verified):
fix/,feat/,feature/,docs/,chore/,uiux/,ui/,ux/,markdown/,bug/,refactor/,ci/,release/. Preferfix/,feat/,docs/,chore/for new work;uiux/for visual polish;markdown/for syntax-feature work. - Small, logical commits; commit frequently. A commit that mixes the fix with a drive-by refactor is two commits done wrong.
- NEVER auto-push, open a PR, or merge. Only when the maintainer explicitly asks. No exceptions for "it's just docs."
- Never discard uncommitted changes. No
git checkout -- .,git reset --hard,git cleanon a dirty tree without explicit permission. - Commit messages follow the observed style:
fix(editor): …,docs: …,ui: …,chore: …— short imperative subject.
3. The non-negotiables
Each rule was established by an incident. Verify against the cited doc before arguing an exception.
| Rule | Rationale | Incident |
|---|---|---|
Text storage always equals rawSource; rendering is attribute-only. Never insert/delete display characters — hide delimiters, never strip them. |
Display offset == raw offset (identity mapping) is what every selection, sync, and heal path assumes. Break it and every later edit drifts. | The delete-drift saga: six rounds over months, each recurrence traced to storage/rawSource divergence in some path. docs/investigations/delete-drift-investigation.md. |
TextKit 2 only. Never touch NSTextView.layoutManager; never store NSTextBlock/NSTextTable attributes. |
Either one silently and permanently reverts the view to TextKit 1. A DEBUG tripwire asserts if TK1 engages — heed it. | ARCHITECTURE §2; the tripwire exists because the reversion is otherwise invisible. |
No NSTextAttachment. Images/icons are drawn as overlays. |
TK2 only honors attachments on U+FFFC, which rawSource never contains (see rule 1). |
ARCHITECTURE §2, §5. |
Never draw images on wrapping (multi-line) fragments; use stroked CGPaths instead. |
A TK2 image on a wrapping fragment wedges layout — collapses the fragment to one line. Shapes don't trigger it. | The callout custom-title icon: docs/investigations/archives/callout-title-wrap-investigation.md; fix in ae61644 (stroked path, not image). |
Every storage-touching styling path guards !hasMarkedText() — including async paths scheduled before composition began. |
Mutating storage mid-IME-composition strands the marked text; didChangeText then bails forever on its own guard and every later edit drifts. |
Delete-drift rounds 1–2 (IME stranding cascade). docs/investigations/delete-drift-investigation.md. |
Never assume didChangeText follows every edit. Sync paths must survive a bypassed edit. |
AppKit's drag-move-to-nowhere deletes via shouldChangeText → replaceCharacters and never calls didChangeText, silently freezing rawSource. The heal (scheduleBypassedEditSyncCheck in +EditFlow) exists for this. |
Delete-drift round 4 (9f99795); rounds 5–6 hardened the heal itself. |
Attribute-only restyles that change geometry must invalidateLayout(for:) the range. |
TK2 does not re-measure on attribute change; the fragment keeps a stale frame — empty bands, clipped lines. | ARCHITECTURE §8; recomposeDirty and the idle drain already do this — new paths must too. |
Undo restore is diff-based recomposeReplacing — never a full recompose. |
Full recompose resets every fragment to a TK2 height estimate; the follow-up scroll lands wrong and the viewport drifts. | Undo/redo viewport drift — one of the costliest failures here. Fixed in 5bb2b40 (fix(undo): select + center the changed text; diff-based snapshot restore). |
Never blanket pkill -x edmd. pgrep -x edmd first, check start times, kill only the PID you launched. |
The maintainer's daily-driver app shares the binary name. A blanket pkill kills their editor with their work in it. (ARCHITECTURE §1's pkill -x edmd shorthand predates this rule — don't copy it.) |
Established after the maintainer's own instance was killed during a debugging session. |
| Never request macOS Computer Access (Screen Recording / Accessibility). | Both are already granted to the tools you use. Requesting again re-prompts the maintainer and can wedge TCC state. | CLAUDE.md "Environment"; the -debug.reproScript driver exists precisely so repros need no new TCC grants. |
| Visual judgments ("balance the padding", "is it centered") are MEASURED from screencapture pixels, not eyeballed. | Eyeballed "looks right" repeatedly shipped asymmetric spacing. Crop the window, count pixels, state the numbers. | Maintainer's explicit rule from UI-polish rounds (status-bar / table-padding branches). |
Files in test-files/ are the maintainer's manual test corpus. Never rewrite them for automation; test-files/todo.md especially is owner-edited. |
They encode the maintainer's by-hand regression walk. Automation churn destroys that. Create your own fixtures in a scratch dir or Tests/. |
Standing maintainer rule. |
| Never ship a fix for a live-input-layer bug (caret, IME, drag, selection timing) on reasoning alone. A frozen repro script must falsify the bug before and confirm the fix after. | This bug class does not reproduce headless (the test harness runs TK2's queued fixup synchronously). Reasoning about deferred AppKit machinery has a ~0% shipping record here. | Delete-drift rounds 1–5 each shipped a plausible fix; each came back. Round 6 finally held because the ReproScript driver reproduced the drift deterministically first. docs/dev-guides/live-repro-guide.md. |
The two incidents that shaped this table
Worth knowing as stories, because the rules read as pedantry until you see the cost:
- Delete-drift (six rounds). The hardest live problem this repo has had.
A caret that drifted after deletes. Round 1 blamed IME stranding — plausible
fix, shipped, recurred. Round 2 disabled remaining marked-text sources —
recurred. Round 3 stopped guessing and built diagnostics (selection tracing,
event logs). Round 4's diagnostics caught a drag-move deleting storage with
no
didChangeText— the heal was born. Round 5: the heal itself leaped the caret via a stale selection. Round 6 found the actual drift mechanism — TextKit 2's queued_fixSelectionAfterChangefiring at the nextendEditing— and held only because the ReproScript driver could replay the exact keystroke sequence deterministically. Five shipped fixes failed; the one preceded by a frozen repro stuck. That asymmetry IS the change-control policy for this bug class. - Undo/redo viewport drift. Undo restored a snapshot via full
recompose, which reset every fragment to a TK2 height estimate; the follow-up scroll-to-caret then landed wrong and the viewport jumped. The fix (5bb2b40) diffs the snapshot against current text and applies only the changed span withrecomposeReplacing. Moral: in TK2, layout state is part of the document state you must preserve — "re-render everything" is never the safe fallback here, it is the bug.
4. Review expectations
- ARCHITECTURE.md is updated in the same PR whenever you learn something non-obvious or change an invariant. The doc's own header demands this; the gotchas in §8 all arrived this way.
- Quirks are documented as comments at the code site — the edge case, the workaround, the why. Not in commit messages, not in CLAUDE.md.
- New known issues go in ARCHITECTURE §9 with a one-line repro and a
pointer to any deeper write-up in
docs/. - Big investigations (multi-round bugs) get a
docs/*-investigation.mdchronicle — seeedmund-failure-archaeologyfor the pattern.
5. Pre-commit checklist (copy-paste)
The workflow that worked (ARCHITECTURE §12 + CLAUDE.md). Run it verbatim:
[ ] swift test — all green (also enforced by the Stop hook; don't rely on it)
[ ] New behavior / bug fix → a test exists that fails without the change
[ ] Draws anything? → build app, screencapture window-by-id, look at the PNG
[ ] Edit-pipeline / selection change? → live repro or soak script passed
[ ] On a branch off main (fix/…, feat/…, docs/…, chore/…), NOT on main
[ ] Diff touches only what the task needs; style matches surroundings
[ ] Learned something non-obvious? → ARCHITECTURE.md updated in this change
[ ] Quirk introduced/found? → comment at the code site
[ ] Commit is small and logical; message matches repo style (fix(scope): …)
[ ] NOT pushing, NOT opening a PR, NOT merging (unless explicitly asked)
6. Ask the maintainer first — always
Never do these unprompted; ask and wait for an explicit yes:
| Action | Why it's gated |
|---|---|
git push, opening a PR, merging anything |
Standing rule in CLAUDE.md ("Never auto-push, PR, or merge"). The maintainer reviews and merges. |
| Starting or tagging a release | A tag push fires CI, builds, signs, publishes a GitHub Release, and updates the appcast that live users poll. Not reversible quietly. |
| Deleting anything uncommitted (files, stashes, working-tree changes) | "Never delete uncommitted changes" — the maintainer's in-progress work may be in the tree. |
Editing anything in test-files/ |
Manual test corpus; todo.md there is owner-edited. Make fixtures elsewhere. |
| Adding a dependency | Current set is deliberately three (swift-markdown, SwiftMath, Sparkle); each new one is a codesign/bundle/update-pipeline liability (see the SwiftMath bundle saga, ARCHITECTURE §8). |
Changing .claude/settings.json hooks or permissions |
Alters what runs automatically on the maintainer's machine. |
When NOT to use this skill
| You need… | Go to |
|---|---|
| The invariants' full technical statement and render pipeline | edmund-architecture-contract |
| To debug a failure, read traces/logs | edmund-debugging-playbook |
| The history of a past incident in depth | edmund-failure-archaeology |
| TextKit 2 / AppKit API behavior details | textkit2-appkit-reference |
| Launch flags, debug bundle, settings | edmund-config-and-flags |
| Build issues, stale binaries, environment | edmund-build-and-env |
| Executing a release / operating the app | edmund-release-and-operate |
| The screencapture / ReproScript mechanics | edmund-live-repro-and-diagnostics |
| Test-writing patterns and QA strategy | edmund-validation-and-qa |
| Writing docs / chronicles | edmund-docs-and-writing |
| Caret/selection bug-class specifics | edmund-caret-integrity-campaign |
Provenance and maintenance
- Sources:
CLAUDE.md(repo root),docs/ARCHITECTURE.md§1 §2 §8 §9 §12,.claude/settings.json(Stop hook),misc/before-you-release.md,misc/how-to-release.md,docs/investigations/delete-drift-investigation.md(rounds 1–6),docs/investigations/archives/callout-title-wrap-investigation.md,git log/git branch -aas of fe8a1f5 (2026-07-05). - Commits cited were verified in
git log: 5bb2b40 (diff-based undo restore), 9f99795 (round-4 heal), ae61644 (stroked-path callout icon), 1b1420a (round-6 caret re-assert). - Two rules rest on maintainer statements rather than repo docs: the
measure-from-pixels rule and the
test-files/ownership rule. If either gets written into ARCHITECTURE.md, point at it here. - Maintain: when a new incident produces a new rule, add a row to §3 with the
incident pointer in the same PR that adds the rule to ARCHITECTURE.md. When
the Stop hook in
.claude/settings.jsonchanges, update §1's note.
.claude/skills/edmund-config-and-flags/SKILL.md
npx skills add I7T5/Edmund --skill edmund-config-and-flags -g -y
SKILL.md
Frontmatter
{
"name": "edmund-config-and-flags",
"description": "Catalog of every configuration axis in the Edmund Markdown editor — user settings (UserDefaults keys, defaults, where consumed), launch arguments (diagnostic + repro flags), compile-time gates, and logging config. Load when adding or changing a setting, hunting which flag controls a behavior, launching the app with debug flags, auditing defaults, or wiring a new preference into the live editor. This skill drifts fastest of the set — every table ends with a re-verification grep. Not for the invariants (see edmund-architecture-contract), release flags (edmund-release-and-operate), or how to READ the logs (edmund-live-repro-and-diagnostics)."
}
Edmund configuration & flags
Ground-truth catalog of every knob. Code wins over docs — every value below was read from source on 2026-07-05; re-verify with the greps at the end before trusting a value in a decision.
Two source-of-truth files:
Sources/edmd/Settings/AppSettings.swift— every UserDefaults key + typed accessor.Sources/edmd/Settings/*View.swift(Appearance / General / Advanced) +FontSettings— the SwiftUI panes (@AppStorage).
Definitions used below: UserDefaults = macOS per-app persisted key/value store; argument domain = passing -<key> <value> on the command line overrides that default for one launch; @AppStorage = SwiftUI wrapper binding a view to a UserDefaults key.
1. User settings (UserDefaults keys)
Every key is a static let in AppSettings.swift. The key string (not the
Swift name) is what you pass as a launch arg.
| Swift name | Key string | Purpose |
|---|---|---|
reopenWindows |
settings.general.reopenWindows |
Reopen last windows on launch |
startupAction |
settings.general.startupAction |
What to do at startup (new doc / reopen / nothing) |
autoSaveWithVersions |
settings.general.autoSaveWithVersions |
NSDocument autosave-in-place vs versions |
conflictResolution |
settings.general.conflictResolution |
File-changed-on-disk handling |
suppressInconsistentLineEndingWarning |
settings.general.suppressInconsistentLineEndingWarning |
Silence mixed-line-ending warning |
diagnosticLogging |
settings.general.diagnosticLogging |
On/off for file logging (opt-out; see §4) |
logRetention |
settings.general.logRetention |
Days of logs to keep (pruned on configure) |
appearanceMode |
settings.appearance.mode |
Light / dark / system |
maxContentWidthCm |
settings.appearance.maxContentWidthCm |
Max column width, stored in CENTIMETRES (see note) |
contentWidthUnit |
settings.appearance.contentWidthUnit |
Display unit only (cm/in); the stored value is always cm |
renderBlankLinesAsBreaks |
settings.reading.renderBlankLinesAsBreaks |
Read-mode blank-line handling |
sourceMode |
settings.view.sourceMode |
When on, Source replaces Edit in the ⌘E toggle; honored on open |
verboseEditorDiagnostics |
settings.advanced.verboseEditorDiagnostics |
Verbose editor trace (see §4; pairs with diagnosticLogging) |
sendCrashLogs |
settings.advanced.sendCrashLogs |
Opt-in crash upload — currently INERT (see note) |
sentCrashReports |
settings.advanced.sentCrashReports |
Dedup set of already-uploaded .ips filenames |
lastWindowHeight |
settings.window.lastHeight |
Persisted window sizing (see the frame-not-content trap) |
automaticallyChecksForUpdates |
SUAutomaticallyChecksForUpdates |
Sparkle's own key (not namespaced) |
Content width (the physical-column design): persisted as centimetres
(maxContentWidthCm); contentWidthUnit is a display unit only. The column is
an absolute physical cap converted to points via the display's real PPI
(NSScreen.physicalPPI, from CGDisplayScreenSize), applied as a symmetric
textContainerInset.width. Default is locale-aware — 5 in (US) / 12 cm
(elsewhere) — and doubles as the slider's magnetic snap point. Recomputed on
resize and on NSWindow.didChangeScreenNotification. Consumer path lives in
EditorTextView+ContentWidth.swift.
Window sizing trap: persistence must round-trip the frame size, not the
contentView.bounds size — reapplying content size grows the window by the
title-bar + toolbar height on every reopen, and heights below minSize get
silently rejected. Save window.frame.size, reapply with window.setFrame(_:)
after the toolbar is installed. (Note: the key on disk is
settings.window.lastHeight — code, not the lastWindowSize some docs say.)
Crash uploading is inert: sendCrashLogs defaults off AND the Settings ▸
Advanced toggle is commented out in AdvancedSettingsView.swift, and
CrashReporter.reportingEndpoint is a REPLACE-ME.invalid placeholder
(CrashReporter.swift:27, // TODO: real server). Nothing uploads today.
Un-inert it only when a receiving server exists (see edmund-release-and-operate).
2. Launch arguments
macOS reads -<UserDefaults-key> <value> into the argument domain. Pass the
key string from §1, not the Swift name. The file to open must be
argv[1] (before the flags).
build/EdmundDbg.app/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES \
-debug.reproScript SCRIPT.repro \
-ApplePersistenceIgnoreState YES
| Flag | Effect |
|---|---|
-settings.general.diagnosticLogging YES |
Turn on file logging for this run |
-settings.advanced.verboseEditorDiagnostics YES |
Emit the verbose editor trace (sel/active/marked/up/…) |
-debug.reproScript <path> |
DEBUG builds only — replay a keystroke script (ReproScript.swift) |
-ApplePersistenceIgnoreState YES |
Apple's flag — stop state restoration reopening mutated docs |
-debug.reproScript is the only Edmund-specific debug key; it does not have
an AppSettings accessor — it is read directly in ReproScript.swift. It is
compiled out of release builds.
3. Compile-time axes
#if DEBUG gates live in: EditorTextView.swift, EditorTextView+EditFlow.swift,
Diagnostics/Log.swift, edmd/App/main.swift, edmd/App/ReproScript.swift.
What they gate:
- The TextKit-1 tripwire (
EditorTextView.swift:273+): a DEBUG observer onNSTextView.willSwitchToNSLayoutManagerNotificationthat asserts if the view ever falls back to TextKit 1. Ships only in DEBUG; the fallback itself is silent and permanent (see edmund-architecture-contract). - ReproScript — the whole in-process keystroke driver.
- Log level threshold —
Log.swiftcompiles a lower floor in DEBUG (debug+) than release (info+); see §4.
Named tuning constants (not user-facing, but they behave like knobs):
| Constant | Value | Where | Meaning |
|---|---|---|---|
fullLayoutMaxLength |
100_000 |
EditorTextView.swift:80 |
Docs ≤ this many UTF-16 units are kept fully laid out (below the TK2 estimate regime). Consumed at +LazyStyling.swift:133. |
4. Logging config
Read Sources/EdmundCore/Diagnostics/Log.swift and
EditorTextView+Diagnostics.swift.
- API:
Log.{debug,info,error}(_:category:),Log.measure(_:) { … }. - File:
~/.edmund/logs/edmund-YYYY-MM-DD.log, written on a private serial queue. - Config flow:
AppSettings.applyLogging()pushes the toggle + retention intoLog.configureat launch and on change; retention pruning happens there. - Two independent switches:
diagnosticLogging(writes anything at all) andverboseEditorDiagnostics(adds the per-event editor trace). For a live repro you almost always want both on. Verbose lines are gated behindLog.shouldTrace. - The log is opt-out (on by default), retention-pruned; the user only toggles it and picks a retention window in Settings ▸ General ▸ Diagnostics.
Trace-field decoding (sel/active/marked/up/undo/blocks/storLen/rawLen,
⚠︎LEN-MISMATCH, traceSelectionOrigin) is covered in
edmund-live-repro-and-diagnostics and edmund-debugging-playbook — one
home per fact; this skill only says which flags turn the trace on.
5. ReproScript command surface
Sources/edmd/App/ReproScript.swift, DEBUG only. One command per line, #
comments allowed.
| Command | Effect |
|---|---|
sleep <ms> |
wait before next command |
caret <needle> |
place caret before first occurrence of <needle> |
type <text> |
one real key event per char (~80 ms apart) |
backspace <n> |
n real delete keystrokes (~300 ms apart) |
bypassdelete <needle> |
simulate drag-move source deletion: shouldChangeText + storage mutation, no didChangeText |
assertcaret <needle> |
log PASS/FAIL iff caret sits exactly before <needle> |
logsel |
log selection, rawSource length, doc count |
Adding a command is ~10 lines in ReproScript.swift. Usage, soak patterns, and
launch recipe: edmund-live-repro-and-diagnostics.
6. How to ADD a setting (checklist)
Worked from an existing real path (sourceMode / content width). To add a
user-facing setting:
- Add a
static letkey + typed accessor inAppSettings.swift(namespace the key string:settings.<area>.<name>). - Bind it in the relevant SwiftUI pane with
@AppStorage(AppSettings.<key>). - If it must affect open documents live, add/extend an
applyTo…broadcast (see the font/line-height/content-widthapplyTo…helpers) so every openDocument.editorpicks it up — a setting that only takes effect on next open is usually a bug. - Pick a sane default (register it, or make the accessor default when absent).
- New behavior needs a test + (if it draws) a screencapture check — route through edmund-change-control and edmund-validation-and-qa.
When NOT to use this skill
- Understanding why an invariant exists → edmund-architecture-contract.
- Release/signing/appcast flags,
RELEASE_TOKEN, Sparkle keys → edmund-release-and-operate. - Interpreting log output / running a repro → edmund-live-repro-and-diagnostics.
- Which change needs which gate → edmund-change-control.
- Build-time flags in the toolchain sense (stale builds,
swift package clean) → edmund-build-and-env.
Provenance and maintenance
Verified 2026-07-05 against source. This skill drifts fastest — re-verify each table before relying on it:
# §1 all keys + strings:
grep -nE 'static let [a-zA-Z]+ = "[a-zA-Z0-9._]+"' Sources/edmd/Settings/AppSettings.swift
# §1 crash toggle still commented out / endpoint still placeholder:
grep -n 'Crash reports:' Sources/edmd/Settings/AdvancedSettingsView.swift
grep -n 'reportingEndpoint' Sources/EdmundCore/Diagnostics/CrashReporter.swift
# §2 repro flag key:
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
# §3 tripwire + constant:
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
# §5 repro commands:
grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
Known doc-vs-code discrepancies (code wins): ARCHITECTURE §7 calls the
window-size key lastWindowSize; the code key is settings.window.lastHeight
(lastWindowHeight). If you touch window persistence, trust the code.
.claude/skills/edmund-debugging-playbook/SKILL.md
npx skills add I7T5/Edmund --skill edmund-debugging-playbook -g -y
SKILL.md
Frontmatter
{
"name": "edmund-debugging-playbook",
"description": "Load this FIRST when any bug report or unexpected behavior arrives in Edmund: caret lands in the wrong place after delete\/typing, viewport jumps or scroll-to-target misses, rendering is wrong or missing, empty bands or clipped lines, everything suddenly renders as plain text, app crashes or won't launch, a code change \"doesn't take\" after rebuild, undo\/redo lands the viewport wrong, IME\/CJK\/accent input misbehaves, right-click shows the wrong menu, window grows on reopen, or a Sparkle update fails. Symptom-to-mechanism triage table, the traps that cost real debugging time, discriminating trace checks, the repro escalation ladder, and the open-bug inventory so known bugs are not rediscovered fresh."
}
Edmund debugging playbook
Date-stamped 2026-07-05. Runbook for triaging any Edmund bug. Start at the triage table, run the discriminating first check before forming a theory, and check the open-bug inventory before declaring a discovery.
When NOT to use
- Making a change, not chasing a bug →
edmund-change-control. - You already know the bug is live-only and need to build a deterministic
repro →
edmund-live-repro-and-diagnostics(full ladder detail; summary in §5 here). - Caret-drift class specifically, with its six-round history →
edmund-caret-integrity-campaign. - Build/toolchain/stale-binary mechanics beyond the quick checks here →
edmund-build-and-env. - Release, signing, appcast, Sparkle pipeline →
edmund-release-and-operate. - TextKit 2 / AppKit API semantics reference →
textkit2-appkit-reference. - Launch flags and settings keys reference →
edmund-config-and-flags. - How past investigations were run and why →
edmund-failure-archaeology,edmund-research-methodology. - Pre-merge verification of a fix →
edmund-validation-and-qa.
1. First 15 minutes — any new bug
Run this checklist before hypothesizing. Every step is cheap; skipping them is how rounds 1–5 of delete-drift shipped fixes that came back.
- Check the open-bug inventory (§6). If the symptom matches a known open bug, you are done triaging — link the backlog entry and its repro asset.
- Get the logs.
ls -t ~/.edmund/logs/and read the day's file (edmund-YYYY-MM-DD.log). Grep for the three permanent breadcrumbs:
Also grep forgrep -n "healing storage edit that bypassed didChangeText\|repairing content above origin\|recovered stranded desync on focus regain" ~/.edmund/logs/edmund-*.loginvariant:(the always-on storage==rawSource tripwire) and⚠︎LEN-MISMATCH. - Find the first bad line and walk BACKWARDS. The user-visible symptom is often the second half of a two-part mechanism — the round-6 caret drift was armed by a silent bypass 80 seconds and dozens of healthy edits before the leap. Never start reading at the symptom timestamp.
- Rule out a stale build if this follows a rebuild: grep
strings .build/arm64-apple-macosx/debug/edmdfor a long string literal unique to the new code (≤15-byte literals are inlined on arm64 and never appear);shasumthe binary. See §3c. - Check git history for prior art.
git log --oneline -- <suspect file>plus the investigation docs indocs/. The viewport-glitch investigation found four earlier fixes all working around the same unnamed root cause. - Reconstruct the document. Get the user's file or rebuild it from the trace's block counts/lengths. Wrapped-paragraph geometry and block kinds matter; do not repro against "hello world".
- Before touching any running app:
pgrep -lx edmdthenps -o lstart=,command= -p <pid>. The user's daily-driver app shares the binary name. Never blanketpkill -x edmd— kill only the PID you launched. - Row found in §2 → run its discriminating check. No row → escalate per §5, and add the new row here when it's understood.
2. The triage table
| Symptom | Likely mechanism | Discriminating first check | Where next |
|---|---|---|---|
| Caret lands blocks away after delete or typing; text itself correct | Delete-drift class: a storage edit bypassed didChangeText (drag-move to no valid target), or TextKit 2's queued _fixSelectionAfterChangeInCharacterRange fired at a later endEditing |
grep "healing storage edit that bypassed didChangeText" ~/.edmund/logs/edmund-*.log and read the trace around it; look for selectionDidChange with up=Y at a surprising position |
edmund-caret-integrity-campaign; docs/investigations/delete-drift-investigation.md |
| Every delete drifts, persistently, until an app switch fixes it | Stranded IME composition: hasMarkedText() stuck true, didChangeText bails forever, model frozen |
Grep logs for recovered stranded desync on focus regain; check storage.string == rawSource |
docs/investigations/delete-drift-investigation.md rounds 1–2 |
| Scroller jumps; scroll-to-target misses; content shifts on scroll | TextKit 2 height estimates — off-screen frames are guesses corrected as layout reaches them | Doc length vs fullLayoutMaxLength (100k UTF-16, EditorTextView.swift) — ≤100k should be fully laid out by the settle; >100k is estimate territory |
docs/investigations/viewport-glitch-investigation.md |
| First line unreachable above the top; scroller already at 0 | TK2 strands fragments at negative y after a top-of-document edit | grep "repairing content above origin" ~/.edmund/logs/edmund-*.log — present means the repair fired (diagnosis confirmed, repair maybe raced); absent means a different cause |
docs/investigations/viewport-glitch-investigation.md Bug 2 (repair unconfirmed live) |
| Undo/redo lands viewport in the wrong place; changed text not selected | Regression of the diff-based restore contract (5bb2b40): a full recompose resets every fragment to an estimate, then the scroll measures the estimates |
Confirm restoreSnapshot still routes through range-bounded recomposeReplacing, never full recompose (+Undo.swift); check the changed range, not the stored caret, drives the viewport |
docs/investigations/viewport-glitch-investigation.md Bug 1 |
| Code/visual change "doesn't take" after rebuild | STALE BUILD — SwiftPM printed Build complete! without relinking edmd |
strings .build/arm64-apple-macosx/debug/edmd | grep "<long new literal>"; shasum before/after |
edmund-build-and-env; §3c |
| App crashes the instant any LaTeX renders | SwiftMath *.bundle missing from the .app root (its Bundle.module is hardcoded to Bundle.main.bundleURL) |
ls build/Edmund.app/*.bundle |
scripts/build-app.sh copy step; ARCHITECTURE §8 |
| Everything suddenly renders as plain text; all styling gone | Silent, permanent TextKit 1 reversion: an NSLayoutManager API was touched or an NSTextBlock/NSTextTable attribute entered storage |
DEBUG builds assert via the tripwire (textKit1FallbackTripwire, willSwitchToNSLayoutManagerNotification); audit recent diffs for layoutManager / NSTextBlock |
ARCHITECTURE §2; textkit2-appkit-reference |
| Empty bands or clipped lines after a restyle | Attribute-only change without invalidateLayout(for:) — TK2 keeps the stale fragment frame |
Is the misbehaving path a new styling path? recomposeDirty and the idle drain already invalidate; new paths must too |
ARCHITECTURE §8 |
| Weird behavior only while composing CJK / accents / emoji | A storage-touching styling path missing the !hasMarkedText() guard (including async work scheduled before composition began) |
Audit the new/changed path for the guard; check logs for a persisting LEN-MISMATCH during composition | ARCHITECTURE §8; docs/investigations/delete-drift-investigation.md |
Callout at end of file shows an extra colored line not prefixed by > |
KNOWN OPEN BUG — lives in the LIVE incremental restyle path only, not static rendering (a fresh full render is clean) | Confirm against misc/bug-repros/callout-extra-line-rendered-at-bottom.mov |
misc/backlog.md |
| Image leaves a large blank space below it | KNOWN OPEN BUG | misc/bug-repros/image-blank-after.mov |
misc/backlog.md |
| Footnotes don't render (edit or read mode) | KNOWN OPEN BUG | — | misc/backlog.md |
| Right-click on the toolbar view-mode button shows "Customize Toolbar…" | NSToolbar with allowsUserCustomization claims every secondary click over the toolbar, beating view-level handlers |
Verify the DocumentWindow.sendEvent(_:) intercept is intact (it swallows the click inside the button's bounds); note it does not cover true fullscreen |
ARCHITECTURE §8 |
| Window grows by title-bar height on every reopen | Frame-vs-content-size persistence trap: saving contentView.bounds.size and re-applying as contentRect |
Confirm lastWindowSize round-trips window.frame.size via setFrame after the toolbar is installed (Document.swift) |
ARCHITECTURE §8 |
| Sparkle update fails: "The update is improperly signed and could not be validated" | Bundle not sealed: signing only the main binary leaves no _CodeSignature/CodeResources; or the EdDSA keypair diverged from SUPublicEDKey |
Does build-app.sh still seal the whole .app before copying the SwiftMath bundle in? |
edmund-release-and-operate; ARCHITECTURE §8/§13 |
| Callout/overlay icon wedges a wrapping line down to one line | TK2 image-on-multiline-fragment wedge: drawing an image on a wrapping fragment collapses its layout; shapes do not | Is the overlay an NSImage on a fragment that can wrap? Convert to a stroked CGPath (the custom-title callout icon fix) |
docs/investigations/archives/callout-title-wrap-investigation.md |
| Dragging produces no visible selection at all | Not a bug: a selection (possibly whole-document) was already active, and dragging inside an existing selection is AppKit's drag-move gesture | Trace: was there a selectionDidChange with a large sel before the drag began? |
docs/investigations/viewport-glitch-investigation.md Bug 3 phase 1 |
| Viewport oscillates up/down during a steady drag-select | Two scroll policies fighting: drag autoscroll vs a reveal that follows the wrong end of a taller-than-viewport selection | Confirm the scrollRangeToVisible override still reveals the selection's nearest end (+TypewriterScroll.swift, commit 340fcbc) |
docs/investigations/viewport-glitch-investigation.md Bug 3 phase 2 |
| Edits do nothing at all (not drift — dropped) | isUpdating stuck true would make shouldChangeText return false |
Trace shows shouldChangeText never returning OK; distinct from the drift signature |
+EditFlow.swift |
Launching via open shows old behavior |
LaunchServices foregrounded a running instance, or ran a stale cached/translocated copy | pgrep -lx edmd first; launch by direct exec of the bundle binary |
§3e; edmund-build-and-env |
3. The traps that cost real time
Each of these burned hours to days. Read before shipping any fix.
(a) Shipping caret fixes on reasoning alone. Delete-drift rounds 1–5 each
shipped a plausible, well-argued fix — and each came back. Only round 6, the
first with a frozen deterministic live repro (bypassdelete script), named
the actual mechanism (_fixSelectionAfterChangeInCharacterRange queued by a
bypassed edit, firing at the next endEditing) — and its first fix attempt
failed in the repro within a minute, which reasoning would never have
caught. Lesson: time spent making the failure cheap to observe beats time
spent reasoning about the fix. Freeze the repro before writing the fix.
(b) Undo/redo viewport drift. Two defects hid behind one symptom:
restoreSnapshot ran a full recompose (discarding all TK2 layout, so the
follow-up scroll measured freshly manufactured estimates) and performUndo
recorded the redo snapshot with the caret at undo-invocation time, not at
the edit. Lesson: a wrong-scroll symptom can be a geometry bug and a plain
stale-state bug stacked; fix and verify them separately. The contract since
5bb2b40: diff the snapshot, apply via recomposeReplacing, select the
changed text, let the changed range drive the viewport.
(c) Stale binaries produce false conclusions. In round 6, swift build
twice printed Build complete! while linking a stale edmd — the compile
ran, the relink silently didn't — and two "failed" fix iterations were
phantoms. Detect: grep strings on the binary for a long literal unique to
the new code. Cure: swift package clean (or rm -rf .build for release
weirdness). Never hand-delete .build/…/edmd.build/ — that corrupts the
output-file-map and wedges the target until a full clean.
(d) Headless-green ≠ fixed for input-layer bugs. The round-6 unit test reconstructs the exact document and gesture and passes with and without the fix: the test harness runs AppKit's deferred selection fixup synchronously, so the broken state never forms. A green test proves nothing about the live NSTextView / TextKit 2 / input-context class. The scripted live repro is the regression harness; the unit test is only a contract spec.
(e) open -a runs stale cached copies. LaunchServices can foreground an
already-running instance instead of relaunching, and can execute a stale
cached/translocated copy of the bundle — you debug last hour's code. Always
launch by direct exec: build/Edmund.app/Contents/MacOS/edmd file.md &
(after the §1 step-7 pgrep check).
(f) Trusting off-screen fragment y-coordinates. A TK2 fragment's frame is
real only once laid out; everything off-screen, plus total document height,
is an estimate. Any code that measures before ensuring layout of the
viewport↔target span lands wrong (this is Bug 1a, the typewriter-scroll
gotcha, and most historical viewport glitches). Ensure layout for the target
range first, then align to real geometry — and never verify a visual fix
from headless layout: measure from screencapture pixels.
4. Discriminating experiments — cheap checks that split hypothesis spaces
Verbose diagnostics launch (defaults keys are namespaced; the file must be argv[1]):
build/Edmund.app/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES
Or toggle in Settings ▸ Advanced ("Save diagnostic logs" + "Verbose editor
tracing"). Logs land in ~/.edmund/logs/edmund-YYYY-MM-DD.log.
Trace field vocabulary (source of truth:
Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift,
diagnosticState). Every trace line ends with:
| Field | Meaning |
|---|---|
sel={loc,len} |
current selection |
active= |
active block index (or nil) |
marked= |
marked-text range, - if none (non-- outside a live composition = stranded) |
up=Y/N |
isUpdating — Y means the event arrived MID-RECOMPOSE |
undo=Y/N |
isUndoRedoing |
blocks= |
block count |
storLen= / rawLen= |
storage vs rawSource lengths |
⚠︎LEN-MISMATCH |
appended when they differ |
Healthy vs suspect edit orderings:
- Healthy:
shouldChangeText→selectionDidChange(up=N) →synced. A transient⚠︎LEN-MISMATCHbetween those lines is normal (storage moves before rawSource syncs). - Suspect: a
selectionDidChangewithup=Yat a surprising position; a persisting LEN-MISMATCH; ashouldChangeTextwith nosynced/SKIPPED/DEFERREDline after it (bypasseddidChangeText); thehealing storage edit that bypassed didChangeTextbreadcrumb.
traceSelectionOrigin: under verbose tracing, any selection change
arriving mid-recompose (up=Y) or with an unconsumed pendingEdit logs a
condensed call stack naming the AppKit path that moved the caret. This is
what named _fixSelectionAfterChangeInCharacterRange in round 6. If your bug
moves the caret and you don't know who moved it, this answers it in one run.
Walk backwards from the first bad line, not forwards from the symptom. The round-6 drift was armed 80 seconds before the visible leap. Find the first line whose state is wrong, then read earlier.
verifyEditorInvariants (same file): the O(1) length check
(storage.length != rawSource.length) logs an error whenever logging is on
— no verbose toggle needed — so a hard-invariant break always leaves an
invariant: line. The full structural checks (string equality, blocks
reconstruct rawSource, block ranges in bounds) run under verbose tracing and
assert in DEBUG. An invariant: error in a user's log is a model desync,
full stop; triage as the delete-drift class.
If the existing logging didn't capture the deciding fact, add the log line
first and reproduce again — one breadcrumb beats ten speculative fixes. Keep
good ones behind Log.shouldTrace and ship them.
5. The escalation ladder (summary)
Full detail, ReproScript command reference, CGEvent driver, and soak-script
method: edmund-live-repro-and-diagnostics and docs/dev-guides/live-repro-guide.md.
Work down; stop at the first level that reproduces.
- Plain unit test (
makeEditor()) — model/parsing/styling logic. - Windowed unit test (NSWindow + NSScrollView, real
deleteBackward) — adds layout, viewport, first-responder. Failure to repro here is evidence, not defeat: it points at deferred/queued AppKit state and at levels 3–4. - In-process ReproScript — DEBUG builds accept
-debug.reproScript <path>(Sources/edmd/App/ReproScript.swift); replays a keystroke script through the realwindow.sendEventpath. No Accessibility/TCC needed, works with the window on an invisible Space. Commands:sleep,caret,type,backspace,bypassdelete,assertcaret,logsel. Launch with-ApplePersistenceIgnoreState YESand recreate the test document fresh each run. The default for live bugs. - CGEvent driver — only for paths that must originate as HID events (drag-select, drag-move, autoscroll). TCC decides per session; if input doesn't land after one test click, fall back to level 3 immediately.
- Instrumented field occurrence — can't trigger it yourself: add the decisive breadcrumb, ask the user to enable verbose tracing, and wait. Days of latency; make sure the next occurrence is decisive.
After a fix: freeze the repro script, run a soak (several trigger cycles in
one app run with assertcaret checks), then full swift test.
6. Open-bug inventory
Known open bugs — check here before "discovering" one. Sources:
misc/backlog.md (authoritative list) and docs/ROADMAP.md (larger themes,
e.g. "TextKit 2 viewport stabilization" is a v1.0.0 item — viewport estimate
glitches are a known, partially-mitigated class). All entries below are OPEN
as of 2026-07-05.
| Bug | Status | Repro asset |
|---|---|---|
| Delete caret drift (class) | Open as a class; rounds 1–6 fixed, watching for round 7 | misc/bug-repros/delete-caret-drift-{1.mp4,2.mov,3.mov,4.mov} + matching logs |
| Inaccurate viewport estimates & related | Open class; small-doc mitigations shipped, Bug-2 repair unconfirmed live | — |
| Callout as last element renders an extra colored line | Open; live incremental restyle path only, NOT static rendering | misc/bug-repros/callout-extra-line-rendered-at-bottom.mov |
| Image creates large empty space below | Open | misc/bug-repros/image-blank-after.mov |
| Footnotes don't render (edit or read mode) | Open | — |
| Math environments don't render in read mode | Open | misc/bug-repros/math-baseline-read-mode-png.png (related baseline issue) |
| Math environments have wrong padding in edit mode | Open | — |
| Max content width not applied to read mode | Open | — |
| Images should shrink when content size is small | Open | — |
| Tables should wrap when content size is small | Open | — |
| Table cell content wraps out of the cell | Open | — |
| Click-to-select / select+delete sometimes doesn't work | Open, intermittent | — |
| Scroll glitch from height changes outside viewport | Open, unreproduced ("Lurking" in backlog) | — |
| Cursor stuck at indented position after indented editing | Open, unreproduced; awaiting screen recording | — |
| Undo/Redo and Copy/Paste scrolling "failing again" | Open, unreproduced (post-fix recurrence report) | — |
Do not relabel any of these as fixed without a verified repro flip; no
oversell. When you fix one, update misc/backlog.md and this table in the
same branch.
7. House rules while debugging
- Never blanket
pkill -x edmd. The user's daily-driver app shares the binary name.pgrep -lx edmd+ps -o lstart=,command= -p <pid>, then kill only your own PID (pkill -f EdmundDbgif you launched the debug bundle). - Do not request Computer Access — Screen Recording and Accessibility are already granted.
- Measure visuals from
screencapturepixels (capture by window id, see ARCHITECTURE §8), never from headless layout alone. - Never mutate storage while
hasMarkedText()— including in any diagnostic or repro code you add. - Never auto-push, PR, or merge. Branch off
mainper fix; commit small and often. - Logs in
~/.edmund/logsare app-owned and fair game to read and quote.
Provenance and maintenance
Built 2026-07-05 from: docs/ARCHITECTURE.md (§2, §8, §9),
docs/investigations/delete-drift-investigation.md (rounds 1–6),
docs/investigations/viewport-glitch-investigation.md,
docs/investigations/archives/callout-title-wrap-investigation.md, docs/dev-guides/live-repro-guide.md,
misc/backlog.md, docs/ROADMAP.md, and
Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift. Log strings
(healing storage edit that bypassed didChangeText, repairing content above origin, recovered stranded desync on focus regain), launch-flag keys,
fullLayoutMaxLength, ReproScript commands, the TK1 tripwire, and commit
5bb2b40 were verified against the source tree on that date.
Maintain it like the codebase docs: when a new bug class is understood, add
its triage row; when a trap costs real time, add its story to §3; when a
backlog bug opens or closes, sync §6 with misc/backlog.md in the same
branch. If a row's discriminating check stops matching the code (renamed log
string, moved file), fix the row — a stale runbook is worse than none.
Deeper mechanism detail belongs in the sibling skills and docs/
investigation files, not here; this file stays a triage surface.
.claude/skills/edmund-docs-and-writing/SKILL.md
npx skills add I7T5/Edmund --skill edmund-docs-and-writing -g -y
SKILL.md
Frontmatter
{
"name": "edmund-docs-and-writing",
"description": "Documentation of record for the Edmund repo: which doc owns which fact, and how to write in the house style. Load whenever you are writing or updating ANY project doc — docs\/ARCHITECTURE.md, CHANGELOG.md, README.md, docs\/ROADMAP.md, misc\/backlog.md, a docs\/<topic>-investigation.md write-up, release docs — or deciding WHERE a newly learned fact, gotcha, bug, or feature idea belongs. Covers the docs-of-record map, the fact-routing decision table, the investigation-doc template, CHANGELOG format (machine-extracted for release notes), commit-message conventions, and doc maintenance duties. Not for making the code change itself, release mechanics, or debugging — see \"When NOT to use this skill\"."
}
Edmund docs and writing
Date-stamped 2026-07-09. Every claim below was verified against the files on
main at that date; re-verify paths before trusting this after major
reorganizations.
When NOT to use this skill
| You are actually doing | Use instead |
|---|---|
| Changing code / designing a mechanism | edmund-architecture-contract |
| Branch/commit/PR mechanics, pre-commit checklist | edmund-change-control |
| Cutting a release, appcast, Sparkle, CI | edmund-release-and-operate |
| Diagnosing a bug (not writing it up) | edmund-debugging-playbook, edmund-live-repro-and-diagnostics |
| Mining past investigations for technique | edmund-failure-archaeology |
| Marketing copy, positioning, alternatives research | edmund-external-positioning |
| Build flags, env, debug bundle | edmund-build-and-env, edmund-config-and-flags |
This skill is for prose: what to write, where it lives, how it should read.
1. The docs-of-record map — one home per fact
Every fact has exactly one home; everywhere else gets a pointer. All paths exist and are current as of 2026-07-09.
| Doc | Owns | Notes |
|---|---|---|
docs/ARCHITECTURE.md |
HOW the system works: build/test commands (§1), the two invariants (§2), render pipeline (§3), edit/undo flow (§4), TextKit 2 drawing (§5), feature map (§6), settings (§7), gotchas (§8), known issues (§9), code debt (§10), agent quick start (§11), working agreements (§12), release/CI (§13), references (§14) | THE agent-onboarding doc. Its own header states the rule: when you learn something non-obvious or change an invariant, edit this file in the same PR. |
docs/architecture/README.md |
Human developer overview: what Edmund is, the two invariants (summarized, not owned), a map of docs/architecture/'s deep docs and the sibling investigations//dev-guides/ folders, common quirks (each a pointer, never a new claim), getting-started commands |
The human entry point ARCHITECTURE.md's header note links to. Every fact here traces to ARCHITECTURE.md or a deep doc — this file summarizes, never owns. |
docs/architecture/<topic>.md |
Deep narrative write-up of one subsystem (e.g. editor-pipeline.md, text-system.md) |
The "deep-doc" pattern: a fact's statement lives in ARCHITECTURE.md, its explanation lives here, each links to the other. |
docs/architecture/extensibility.md |
The design-of-record for themes/extensions: vision, current state (verified against main and the unmerged feat/extensions-registry-and-tab branch), themes/extensions design, staged implementation plan, honest risks |
Design only, not yet implemented on main. ARCHITECTURE.md gets no extensibility section until code lands (same-PR rule) — this doc is the exception to the deep-doc pattern above: there is no ARCHITECTURE.md statement to expand yet. |
docs/architecture/sandboxing.md |
The App Sandbox preparation plan: CotEditor reference model, touchpoint-to-fix inventory, entitlements/build-variant mechanics, the ~/.edmund/ onboarding grant, staged plan (SB0-SB4), open decisions |
Plan only, nothing sandboxed on main. Same design-doc exception as extensibility.md: no ARCHITECTURE.md statement exists yet; when a stage lands, its facts move to ARCHITECTURE.md in the same PR. |
README.md |
WHAT/WHY for users: differentiators, screenshots, install (incl. the Gatekeeper "DAMAGED" xattr -dr com.apple.quarantine workaround), dependencies, alternatives, acknowledgements, license |
User-facing; no internals. |
CHANGELOG.md |
User-facing version history, Keep-a-Changelog style | ## [x.y.z] sections are machine-extracted for release notes — exact format matters (§4 below). |
docs/ROADMAP.md |
Versioned feature plan: ## v1.0.0, ## v1.x, # v.2.0.0 sections of checkbox lists, grouped by theme (editing, extensions, macOS integrations) |
Has a Last updated: YYYY-MM-DD line under the title — refresh it when you edit. |
misc/backlog.md |
The maintainer's working priority list: ## Now (small releases) (Marketing / On-going bugs / Bugs / UI/UX / Features), ## Next, ## Later, roadmap mirrors, ### Lurking (Unreproduceable), ## Done |
Stated priority: Marketing = Bugs >= UI/UX > Features. Bug entries carry repro pointers (misc/bug-repros/*.mov, .log, or ~/Desktop paths). |
docs/investigations/<topic>-investigation.md |
Deep multi-round investigation chronicles for active bug classes | Existing: delete-drift-, viewport-glitch-investigation.md. Template in §5. |
docs/investigations/archives/<topic>-investigation.md |
Chronicles for closed/resolved bug classes | Existing: callout-bottom-line-, callout-title-wrap-investigation.md. |
docs/dev-guides/live-repro-guide.md |
Method doc: the escalation ladder for reproducing live-app bugs | Referenced from ARCHITECTURE §11. |
misc/before-you-release.md |
Pre-flight readiness checklist | Pairs with how-to-release.md; cross-ref edmund-release-and-operate. |
misc/how-to-release.md |
Release mechanics (CI tag path, local release.sh) |
Same. |
CLAUDE.md (root) |
Behavior contract for agents: env, git practices, pre-commit checklist, the comment-at-the-code rule | Short by design; it delegates the "how" to ARCHITECTURE. |
LICENSES/ |
Vendored license texts (currently lucide.txt for the Lucide icon SVGs) |
Add one when vendoring third-party assets. |
Info.plist |
CFBundleShortVersionString + CFBundleVersion — the version of record |
Must match the CHANGELOG section header at release (see misc/before-you-release.md §3). |
Note: misc/backlog.md and docs/ROADMAP.md currently duplicate the
v1.0.0/v1.x/v2.0.0 sections (backlog carries an extended copy). ROADMAP is the
public plan; backlog is the working list. When they disagree, treat ROADMAP as
the versioned commitment and backlog as scratch — and mention the drift to the
maintainer rather than silently reconciling.
2. Where does a new fact go — decision table
Route the fact FIRST, then write. One home; cross-reference from elsewhere.
| You learned / produced | Home | How |
|---|---|---|
| Code quirk, edge case, workaround, non-obvious why | Comment at the code site | House rule (root CLAUDE.md): "Document non-obvious behavior... as a short comment at the code itself — not in commits or this file." |
| Architectural gotcha that will bite the next agent | ARCHITECTURE.md §8 |
Bold lead-in bullet + one-line repro/symptom + pointer to any deeper write-up. Same PR as the code change. |
| New known issue / structural constraint | ARCHITECTURE.md §9 |
It has an explicit placeholder: "Add new ones here as you find them — with a one-line repro and a pointer to any deeper write-up in docs/." |
| Code debt / incomplete implementation | ARCHITECTURE.md §10 |
Its footer says: track code-debt here, roadmap items in README/ROADMAP. |
| Changed invariant, new subsystem, new pipeline step | ARCHITECTURE.md §2–§7 (the relevant section) |
Update in the same PR — header rule. |
| Multi-round investigation (2+ hypothesis cycles, live repro work) | New docs/<topic>-investigation.md |
Use the §5 template. ALSO add a one-bullet §8 gotcha summarizing the rule it produced, pointing at the doc. |
| User-visible change (fix/feature/rename) | CHANGELOG.md under the next ## [x.y.z] |
Format in §4. Link the issue and any investigation doc. |
| New bug found (reproducible) | misc/backlog.md under Bugs |
- [ ] Bug: <symptom>. See <repro pointer>. Drop repro assets (video/log) into misc/bug-repros/. |
| New bug found (unreproducible so far) | misc/backlog.md → ### Lurking (Unreproduceable) |
One line + "wait for screen record" style note. |
| Bug that is really code debt (design limitation) | ARCHITECTURE.md §9 |
e.g. the image-on-wrapping-fragment constraint. |
| Feature idea, near-term (next few small releases) | misc/backlog.md (Now/Next/Later) |
Sorted by priority + difficulty within category. |
| Feature idea, versioned/strategic | docs/ROADMAP.md under the right version |
Refresh Last updated. |
| Repro method / debugging technique | docs/dev-guides/live-repro-guide.md |
Method docs, not per-bug chronicles. |
| Release procedure change | misc/how-to-release.md / misc/before-you-release.md + ARCHITECTURE.md §13 |
§13 owns the mechanism + failure modes; misc/ owns the operator checklist. |
| Agent workflow improvement | ARCHITECTURE.md §12 |
Its footer invites this: "If you (the agent) improve this workflow... update this section." |
| Vendored third-party asset | LICENSES/<name>.txt + a feature-map note in §6 |
Follow the Lucide precedent. |
| Deep explanation of an existing subsystem | docs/architecture/<topic>.md |
A fact's statement lives in ARCHITECTURE.md; its explanation lives in the deep doc; each links to the other. |
The same-PR rule is the load-bearing one. Doc updates that ride the code
PR actually happen (see cf10741, b600e12, c4a602b in history); doc
updates deferred to "later" don't.
3. House style
Derived from reading ARCHITECTURE.md and the investigation docs. Match it.
- Dense, specific, evidence-first. State the mechanism and the proof, not vibes. "Verified against that exact API" (§8 Sparkle bullet), timestamps and selection ranges quoted verbatim in investigation docs.
- Bold lead-ins for gotcha bullets, then the explanation:
- **Stale release builds**: .... Scannable list, detail inline. - Backticks for every file, symbol, flag, and command:
`recomposeDirty`,`+EditFlow`(the extension-file shorthand),`-debug.reproScript`. - One-line repro pointers, not embedded essays: "See
misc/bug-repros/image-blank-after.mov", "grep~/.edmund/logsforrepairing content above origin". - Honest status labels. The docs say "unconfirmed live", "theory + targeted repair, not a confirmed kill", "Verification limits (honest gaps)", "the test documents intent; the leap only reproduces under live layout". Never claim verification you didn't do. No oversell.
- Section anchors as cross-refs: "see §8", "ARCHITECTURE §13" — used
across ARCHITECTURE, CLAUDE.md, before-you-release.md. If you renumber
sections, grep the repo for
§and fix every reference. - Address "you", the next agent/engineer: "will bite you", "Context for anyone who sees the bug again", "Next time it happens: ...".
- Record what failed, not just what worked — investigation docs keep the overturned theories and the phantom fixes (stale-binary trap) because the dead ends are the reusable knowledge.
Commit messages (from git log --oneline -50)
Mixed but patterned: conventional prefixes dominate for fixes and docs —
fix(scope): ... (scopes seen: editor, layout, scroll, undo,
release-workflow, changelog-to-html), docs: ..., occasional
refactor:, appcast: add Edmund X.Y.Z, release X.Y.Z. Chores and README
work often use plain imperative subjects ("Update README", "Add assets for
README"). Branches: fix/<slug>, docs/<slug>, chore/<slug>. When in
doubt: fix(scope): for behavior changes, docs: for doc-only commits,
plain imperative for chores. Never auto-push, PR, or merge — only when asked.
4. CHANGELOG format — machine-read, get it exact
.github/workflows/release.yml extracts release notes with:
awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}"
So the section header MUST be ## [x.y.z] at line start, version matching
CFBundleShortVersionString exactly; the section ends at the next ## [.
scripts/changelog-to-html.py converts the same section to HTML for
Sparkle's update dialog (it folds wrapped bullet lines into their <li> —
wrapping bullets is safe). Full pipeline: edmund-release-and-operate.
House format (verify against the file; current entries follow this):
## [0.1.4] — 2026-07-XX
### Fixed
- <User-facing symptom, past tense optional> ([docs](docs/<topic>-investigation.md)) [#NNN](https://github.com/I7T5/Edmund/issues/NNN)
---
- Em dash between version and ISO date;
---separator between versions. - Subsections used so far:
### Added,### Changed,### Fixed(Keep a Changelog 1.1.0 vocabulary). - Entries describe the user-visible effect, not the mechanism; mechanism lives in the linked investigation doc / ARCHITECTURE.
- An optional free-text line under the header is fine (0.1.2 has one) — the awk extraction includes it.
5. The investigation-doc template
Derived from docs/investigations/delete-drift-investigation.md (6 rounds) and
docs/investigations/viewport-glitch-investigation.md. Both open with why the doc exists
("Context for anyone who sees the bug again... records the trail end to
end") and name the fixing commits/branch up front. Chronicle structure: each
recurrence is a new ## Round N appended to the same doc — symptom →
diagnosis → root cause → fix → verification, with limits stated.
Skeleton (copy-paste):
# <Area> "<bug nickname>" — investigation notes
Context for anyone who sees this again. <One line on why it was hard:
intermittent / state-dependent / looked nothing like its cause.>
Fixed on branch `fix/<slug>`, commits: `<sha>` — <subject>, ...
## Symptom
<Exact user-visible behavior. Bulleted key properties, each a discriminating
fact ("caret-only, text fine"; "never right after launch"). Evidence
pointers: `misc/bug-repros/<file>`, `~/.edmund/logs/...`.>
## How it was diagnosed
1. <Numbered steps in the order they happened, including overturned
theories and WHY each clue narrowed the space.>
## Root cause
<The mechanism, in bold where it matters. Explain why every symptom
property follows from it.>
## The fix
<What changed, in which file, and why that shape (defenses tried and
rejected count too).>
## Verification
<Tests added, live repro results, suite count. Then an honest limits
subsection: what was NOT reproduced/confirmed, and the breadcrumb to grep
for if it recurs.>
## If it ever recurs
<Ordered checks for the next investigator: which invariant/log/flag to
inspect first.>
## Round 2: <one-line summary> ← append on recurrence, same structure
After writing one: add the one-bullet gotcha to ARCHITECTURE §8 with a
pointer, add the CHANGELOG entry with a ([docs](docs/...)) link, and check
the corresponding misc/backlog.md box (or move it under On-going bugs).
6. Maintenance duties
Do these whenever you touch the relevant doc; they rot otherwise.
- ARCHITECTURE placeholders: §9 and §10 end with italic "Add new ones here" / "track code-debt here" lines — keep them last in their lists so the invitation stays visible.
- ROADMAP
Last updated:— bump the date on any edit. - Backlog hygiene: check
- [x]boxes when a fix ships (move to## Doneonly if following the existing pattern — completed items live there); keep repro pointers valid; don't reorder the maintainer's priority sorting. - README's inline HTML comments are the maintainer's own edit notes
(e.g.
<!-- Replace "minimal" with ... -->) — leave them unless acting on them. - At release: CHANGELOG section header ↔
Info.plistversion ↔ appcast<item>must agree; the checklist ismisc/before-you-release.md, the mechanicsedmund-release-and-operate. - Section renumbering in ARCHITECTURE: grep the whole repo (docs, misc,
CLAUDE.md, skills) for
§references before and after. - Never edit
test-files/todo.md— the maintainer owns it.
Provenance and maintenance
Written 2026-07-05 against main at fe8a1f5 (release 0.1.3). Sources, all
read directly: docs/ARCHITECTURE.md (header, §8–§13),
README.md, CHANGELOG.md, docs/ROADMAP.md, misc/backlog.md,
docs/investigations/delete-drift-investigation.md, docs/investigations/viewport-glitch-investigation.md,
docs/dev-guides/live-repro-guide.md (§1), misc/before-you-release.md,
misc/how-to-release.md, root CLAUDE.md,
.github/workflows/release.yml (awk extraction quoted verbatim),
git log --oneline -50 (commit-style tally), directory listings of
docs/ (architecture/, investigations/ incl. archives/, dev-guides/),
misc/, misc/bug-repros/, LICENSES/.
§1 map re-verified 2026-07-09 against the docs/ reorg (investigation docs
split into docs/investigations/ + docs/investigations/archives/;
docs/live-repro-guide.md moved to docs/dev-guides/).
Maintain this skill when: a doc of record moves or splits (update the §1
map), ARCHITECTURE sections are renumbered (fix every § reference here),
the CHANGELOG extraction in release.yml changes (§4 quotes it), or a new
investigation doc establishes a better template. Keep the one-home-per-fact
rule itself stable — it is the point of the skill.
.claude/skills/edmund-external-positioning/SKILL.md
npx skills add I7T5/Edmund --skill edmund-external-positioning -g -y
SKILL.md
Frontmatter
{
"name": "edmund-external-positioning",
"description": "Load when writing anything an outsider will read about Edmund — README edits, blog posts, release notes, marketing copy, social posts, webpage text, Show HN drafts — or when comparing Edmund to other editors (Typora, Obsidian, MarkEdit, Nodes), deciding what may be publicly claimed vs. what is still unproven, labeling a technique \"novel\", or planning ecosystem work (licenses, attribution, notarization messaging, appcast, issue templates, discovery listings). This skill is the overclaim firewall: what the positioning is, what evidence backs each claim, and what must exist before a claim gets stronger."
}
Edmund external positioning — what we claim, what we can prove
Governing rule: nothing may be claimed publicly that an outsider cannot reproduce from the repo + a release. Unproven = "candidate", never "novel" or "first". This skill exists to prevent overselling a beta.
When NOT to use this skill
| You are doing | Use instead |
|---|---|
| Actually cutting a release (tags, appcast, Sparkle, CI) | edmund-release-and-operate |
| Internal docs, ARCHITECTURE.md, investigation chronicles | edmund-docs-and-writing |
| Understanding the invariants / render pipeline itself | edmund-architecture-contract |
| Deciding whether a code change is allowed | edmund-change-control |
| Reproducing or debugging a bug | edmund-debugging-playbook, edmund-live-repro-and-diagnostics |
| Judging research novelty for internal direction (not public claims) | edmund-research-frontier, edmund-research-methodology |
| Verifying behavior before shipping | edmund-validation-and-qa |
1. The positioning (quote it exactly)
Source of truth: README.md. As of 2026-07-05:
- One-liner: "Edmund is a minimal, file-based, native Markdown editor for
macOS with inline live preview."
- README carries its own TODO comment on this line: "Replace 'minimal' with 'customizable' or 'lightweight' once more features are implemented" — do not do that replacement early; "minimal" is the honest word today.
- Goal statement: "Our goal is to be the CotEditor of Markdown editors, i.e. elegant, powerful, configurable, and native inside out."
- Beta warning: "⚠️ Edmund is currently in beta." — this must stay visible in the README and any landing page until v1.0.0 ships.
- Maintainer's blog post: https://i7t5.com/posts/2026-06-26-edmund/ ("more of the motivation and design philosophy"). Cite it; do not paraphrase or invent its content without fetching it.
- Ambition framing: product-first. "Beyond state of the art" means product excellence — the TextKit 2 techniques are means, not ends. Never lead public copy with internal mechanism names; lead with what the user gets.
The six claimed differentiators (README, verbatim, 2026-07-05)
| # | Claim (verbatim) |
|---|---|
| 1 | "Live preview: Typora/Obsidian-style WYSIWYG." |
| 2 | "File-based: Open .md files from anywhere. No vaults or dedicated folders required." |
| 3 | "Native: 100% Swift. Based on AppKit and TextKit 2. No Electron. Minimal dependencies." |
| 4 | "Fast: Handles ~1-2MB files with ease. No launch lag." |
| 5 | "Extensible: Opt-in math and Obsidian syntax. Extensions system coming soon!" |
| 6 | "Private: Offline by default. Optional blocking of external links and HTML sanitization." |
README also has a TODO comment after the list: "Move 'Fast' and 'Extensible'? Add 'integrations' section to Native after implementation" — the list is known-provisional; keep quotes synced to the file when you edit copy.
2. Claims discipline
Before strengthening any claim publicly, the evidence in the middle column must be upgraded to the right column. Status as of 2026-07-05.
| Claim | Current evidence | Required before strengthening |
|---|---|---|
| Fast: "~1-2MB files with ease. No launch lag" | Tests/EdmundTests/PerfHarnessTests.swift (gated MD_PERF=1, default 1.5MB doc, prints latencies; assertions are deliberately "sanity bounds, not budgets"); viewport-first lazy styling with fullLayoutMaxLength = 100_000 regime (EditorTextView.swift:80) |
A reproducible public benchmark: pinned document + hardware noted + numbers an outsider can rerun (MD_PERF=1 swift test). No comparative "faster than X" claims without benchmarking X the same way. |
| Extensible: "Extensions system coming soon!" | Extensions API is docs/ROADMAP.md v1.0.0 — not shipped. Only opt-in math + Obsidian syntax exist today. README already hedges with "coming soon" |
Keep it hedged until the API + docs + at least one working extension ship. Never write "extensible via plugins" in present tense. |
| Private: "Offline by default" | Grounded: Read webview disables JavaScript; all assets inlined as data URIs (math, icons, local images); remote images off by default (ReadRenderOptions.allowRemoteImages = false); inline HTML whitelisted via HTMLRenderer.sanitizeInlineHTML |
Note: the "Block external images" Settings checkbox is a misc/backlog.md item, not shipped; "optional blocking of external links" in README is forward-leaning — verify against code before repeating it elsewhere. Exceptions to name if asked: Sparkle update check, opt-in crash-log upload (§7 ARCHITECTURE). |
| Native: "100% Swift … No Electron. Minimal dependencies" | True: SwiftPM, three deps (swift-markdown, SwiftMath, Sparkle) + vendored Lucide SVGs. Read mode is a WKWebView (system WebKit, JS off) — that is not Electron, but don't say "no web views" | Nothing; this claim is safe. Just never inflate to "zero dependencies". |
| Live preview: "Typora/Obsidian-style" | Shipped and demoed (README video, screenshots) | Safe. Comparative feature-parity claims vs. Typora/Obsidian need a feature-by-feature check first. |
| File-based: "No vaults" | Shipped by design | Safe. |
| Beta status | v0.1.3 (2026-07-04) | Must stay visible everywhere until v1.0.0. |
3. Novel vs. known — the honest inventory
When writing a craft blog post or comparison, keep this ledger straight. "Candidate" means blog-worthy pending proof; it is not "proven novel".
Known / prior art (never claim novelty here)
- Live-preview Markdown editing: Typora, Obsidian, MarkText, Nodes. MarkEdit is the stated reference for source mode (ROADMAP v2.0.0 "the MarkEdit experience").
- TextKit 2 viewport virtualization for Markdown: nodes-app/swift-markdown-engine (Apache 2.0, macOS 14+) solves the same problems — viewport virtualization, live styling, wiki links, reading column, LaTeX. Per ARCHITECTURE §14: consult it before inventing a new mechanism and as a technique source (drag-select autoscroll, overscroll). Its existence caps any "first TK2 live-preview engine" claim at zero.
- The README's own Alternatives section credits ~15 editors. Public copy that ignores them reads as either ignorant or dishonest.
Distinctive candidates (label as such; each needs proof before publishing)
| Candidate | Why it might be blog-worthy | Proof needed first |
|---|---|---|
| Attribute-only rendering with the storage == rawSource invariant (no attachment characters, no U+FFFC; delimiters hidden, never stripped; identity offset mapping) | A clean architectural answer to the classic WYSIWYG mapping problem | A survey showing how the named alternatives (incl. swift-markdown-engine) handle storage vs. display; the invariant's consequences demonstrated with runnable examples |
Stroked-CGPath overlay workaround for the TK2 image wedge (image in a fragment overlay collapses the fragment's layout to one line; callout icon drawn as stroked path from vendored Lucide SVG instead) |
A concrete, reproducible TK2 bug + workaround — the classic useful engineering post | A minimal frozen repro of the wedge outside Edmund; macOS version range where it reproduces |
Bypassed-didChangeText heal + caret re-assertion (round-6 mechanism: TK2 leaves a _fixSelectionAfterChange queued after a bypassed edit; next-run-loop sync check heals storage and re-asserts the caret) |
Deep TK2 internals nobody has documented; the delete-drift chronicle (docs/investigations/delete-drift-investigation.md) already exists as raw material |
The frozen ReproScript repro kept green; behavior confirmed on current macOS before publishing (private-method behavior can change under us) |
| Diff-based undo restore that preserves TK2 layout (snapshot restore diffs rather than replaces, bypassing NSTextView undo) | Practical fix for a visible TK2 pain (undo viewport yank) | Before/after measurements (layout work saved, viewport stability) on a pinned document |
In-process ReproScript methodology (-debug.reproScript, keystroke replay without CGEvents/TCC; docs/dev-guides/live-repro-guide.md) |
Reusable testing methodology for any AppKit text app | Show it reproducing a real bug end-to-end in a fresh checkout; that IS the reproducibility standard |
Reproducibility standard for any technical post: a reader with the repo and the post must be able to reproduce every claim — frozen repro scripts, pinned document fixtures, named macOS versions, measured numbers with the command that produced them. If a claim can't meet that, cut it or mark it anecdotal.
4. Ecosystem and license hygiene
Verified against the repo, 2026-07-05:
- License: Apache 2.0 (
LICENSE, README "License" section, and the 0.1.0 changelog entry all agree). Say "Apache 2.0", never "MIT". - Lucide icons: vendored, ISC (
LICENSES/lucide.txt, © 2026 Lucide Icons and Contributors; parts derived from Feather). Attribution duty: keepLICENSES/lucide.txtshipping and credit Lucide where icons are discussed. - Why Lucide in both modes (SF Symbols constraint): ARCHITECTURE §6 —
SF Symbols cannot ship in exported PDFs (license), so callout headers use
Lucide in both Read (inline SVG,
currentColor-tinted) and Edit (rasterized tintedNSImageoverlay). App-chrome SF Symbols (toolbar/settings) are fine; Edit-mode task checkboxes still use SF Symbols on-screen only. Don't "simplify" copy or code in a way that breaks this split. - Dependencies to credit: swift-markdown, SwiftMath, Sparkle (README "Dependencies"). Acknowledgements section additionally credits swift-markdown-engine/Nodes, Typora, theme sources, create-dmg, MarkEdit, and others — preserve it when restructuring the README.
- Not notarized (2026-07-05): ad-hoc signed; users hit Gatekeeper
("damaged app"). README's WARNING block gives the two workarounds
(System Settings → Privacy & Security → Open Anyway; or
xattr -dr com.apple.quarantine /Applications/Edmund.app, maybesudo). Keep those instructions accurate in every venue that mentions installing.misc/marketing/MARKETING.mdgates Show HN on fixing this (notarize, or make the workaround idiot-proof). - GitHub issue templates exist:
.github/ISSUE_TEMPLATE/bug_report.md,feature_request.md. Point users there, not at email. appcast.xmlis a public artifact served raw from the repo (SUFeedURLpoints at the raw GitHub URL). Anything committed to it is user-visible in Sparkle's update dialog. Pipeline details: edmund-release-and-operate.
5. Release-notes and public-writing style
- Pipeline:
CHANGELOG.mdsections become both the GitHub release notes (awk-extracted) and Sparkle's update-dialog HTML (scripts/changelog-to-html.py→ appcast<description>). A CHANGELOG entry IS public copy — write it that way. Mechanics: edmund-release-and-operate. - Actual house style (read
CHANGELOG.md0.1.0–0.1.3 before writing): Keep-a-Changelog headers (### Added / Changed / Fixed); one line per item, sentence case, no trailing period enforced; links to issues ([#156]) and investigation docs (([docs](docs/investigations/delete-drift-investigation.md))); user-visible phrasing ("Redo now jumps to where changed text was instead of caret") not internal jargon; occasional first-person maintainer notes with personality ("trying to have Fable 5 fix all the big bugs while I still have it with me"); 0.1.0 used bold Feature — one-line descriptions. Match this voice: plain, specific, lightly informal, zero hype. - Screenshots/videos: README embeds live in
docs/assets/(v0.1.0_*.png,installation.png,v0.1.0_video.mp4,AppIcon/). Raw/source marketing assets live inmisc/marketing/:MARKETING.md(the plan),reddit-post.md,demo-slide-v0.1.key,demo-src-files/, demo videos (demo.mov,demo-video-v0.1-brown.mp4),_rawscreenshot masters,social-preview_figma.png. New public screenshots: polished copy →docs/assets/, raw master →misc/marketing/, versioned filenames.
6. Marketing priority context (2026-07-05)
From misc/backlog.md "Now": "Priority: Marketing = Bugs >= UI/UX >
Features" — marketing work is tied for top priority with bug fixes.
Open marketing items: screenshots/files and a webpage (reference:
kruszoneq.github.io/macUSB). Backlog embeds a star-history.com chart;
misc/marketing/MARKETING.md names GitHub stars (~69 at writing) as the goal
and metric, audience "developers who value craft", and holds Show HN in
reserve until first-run friction and a landing page are fixed. Its "craft
months" deep-dives are exactly the Section 3 candidates — which is why the
proof bar there matters.
Provenance and maintenance
- Sources verified 2026-07-05 against:
README.md,docs/ROADMAP.md(last updated 2026-07-03),misc/backlog.md,docs/ARCHITECTURE.md(§2, §6, §8, §13, §14),CHANGELOG.md(0.1.0–0.1.3),LICENSE,LICENSES/lucide.txt,.github/ISSUE_TEMPLATE/,misc/marketing/,Tests/EdmundTests/PerfHarnessTests.swift,Sources/EdmundCore/Export/{ReadRenderOptions,HTMLRenderer,DocumentHTML}.swift,Sources/EdmundCore/TextView/EditorTextView.swift. - Volatile facts are date-stamped inline: version (0.1.3), beta status, notarization status, shipped-vs-roadmap feature split, star count, README wording. Re-verify each against the file before repeating it publicly.
- When README differentiators or the one-liner change, update the verbatim quotes in §1 and re-run the §2 evidence check.
- If a §3 candidate ships as a published post, move it out of "candidate" and link the post + its frozen repro.
- Cross-references: edmund-release-and-operate (release/appcast mechanics), edmund-docs-and-writing (internal doc style), edmund-research-frontier (novelty judgment for research direction), edmund-architecture-contract (the invariants quoted here).
.claude/skills/edmund-failure-archaeology/SKILL.md
npx skills add I7T5/Edmund --skill edmund-failure-archaeology -g -y
SKILL.md
Frontmatter
{
"name": "edmund-failure-archaeology",
"description": "The chronicle of every major bug investigation, dead end, rejected fix, and revert in the Edmund Markdown editor. Load BEFORE re-investigating any caret\/selection symptom (drift, jump, desync), any viewport\/scroll glitch (lurch, oscillation, wrong landing, can't-scroll-up), any rendering wedge (clipped wrap, one-line collapse, blank space), or any release\/update failure (signing, appcast, Sparkle \"improperly signed\"). Load before proposing a fix that might already have been tried and reverted, when a bug report \"looks familiar\", when a test passes but the live app still misbehaves, or when wondering why the code does something weird (a guard, a re-assert, a deliberately-missing icon). Every entry: symptom, root cause, evidence (commit hashes, docs), status, and what NOT to retry."
}
Edmund failure archaeology
Chronicle of settled battles. Purpose: nobody re-fights one. Each entry gives
symptom → root cause → evidence → status. Hashes are on main unless noted.
Dates are commit dates. Status vocabulary: settled (root-caused, fix
verified), mitigated-unconfirmed (fix shipped, never seen killing a live
occurrence), open (in misc/backlog.md), reverted-pending-redo.
When NOT to use this skill
- Designing a change / asking "why is it built this way" →
edmund-architecture-contract. - Actively debugging a NEW symptom (method, not history) →
edmund-debugging-playbook. - Driving the live app to reproduce something →
edmund-live-repro-and-diagnostics(anddocs/dev-guides/live-repro-guide.md). - TextKit 2 / AppKit API semantics →
textkit2-appkit-reference. - Cutting or fixing a release →
edmund-release-and-operate(this file only records how 0.1.0 broke). - Build environment, stale-link traps in depth →
edmund-build-and-env. - Deciding whether a change is safe to make at all →
edmund-change-control. - Test strategy / what the suite can and cannot catch →
edmund-validation-and-qa. - The ongoing caret-integrity program (forward-looking) →
edmund-caret-integrity-campaign. - Debug flags (
-debug.reproScript, verbose tracing toggles) →edmund-config-and-flags.
1. The delete-drift saga (rounds 1–6) — issue #156
The hardest bug in the project's history: pressing Delete moved the caret to a
different line instead of deleting. Six rounds, 2026-06-25 → 2026-07-04.
Full trail: docs/investigations/delete-drift-investigation.md (read it before touching
+EditFlow / +SelectionTracking / the heal). One symptom, FOUR distinct
root causes stacked on top of each other — each fix was real, and each round's
recurrence was a different mechanism underneath.
STATUS: settled through round 6 (shipped in 0.1.3, 2026-07-04). The class — live-only caret/selection desync — remains the project's hardest problem; new rounds are possible. Backlog still lists "Delete caret drift" under On-going bugs as a class to watch, not a known unfixed defect.
Round 1 — stranded IME marked text (2026-06-26, 386604b + docs ef3d87e)
- Symptom: once it started, EVERY delete drifted; never at launch; cleared by switching apps and back. Text stayed correct — caret-only desync.
- Root cause: every styling path bails on
hasMarkedText()(correct during live IME composition). A stranded composition (hasMarkedText()stuck true, no live composition) madedidChangeTextbail forever →rawSource/blocksfroze while storage kept mutating → all caret math ran against a stale model. Strander: the async active-block restyle in+SelectionTrackingre-checkedisUpdatingbut nothasMarkedText(), so it could runrecomposeDirtyover a live composition scheduled one turn earlier. - Fix: (a) add the missing
!hasMarkedText()guard to the async restyle; (b)becomeFirstResponderrecovery hook —unmarkText()+ resync when the invariant is broken on focus regain (made the user's accidental focus-switch cure deterministic). - Why it came back: the guard closed one strander; other marked-text sources existed (round 2), and other desync mechanisms entirely (rounds 4–6).
Round 2 — marked text without "IME" (2026-06-27, a1f3219)
- Symptom: recurred with no CJK/accent/emoji input. Focus-switch still cured it.
- Root cause (by elimination, documented in the doc): still stranded marked text — from automatic text completion / inline predictions, which inject provisional marked text on plain typing.
- Fix:
isAutomaticTextCompletionEnabled = false,inlinePredictionType = .noincommonInit, plus a permanentLog.infobreadcrumb in the recovery hook. - Why it came back: the next recurrence wasn't marked text at all.
Round 3 — no fix; built diagnostics instead (2026-06-28, 5dae387, 3aaeb04, PR #139)
- Symptom: recurred on a build with rounds 1–2. NO
recovered stranded desynclog line; a headless probe of the exact gesture showed the model was CORRECT. Only appeared after minutes of editing in one window. - Conclusion: the model/parse layer is sound; the drift is a live NSTextView / TextKit 2 / input-context phenomenon invisible headless. Chasing it blind was declared the wrong move.
- Shipped: verbose editor tracing (Settings ▸ Advanced,
Log.trace, category.edit, per-event live-state prefix) + an always-on O(1) invariant tripwire (verifyEditorInvariants, logserroron length mismatch). This instrumentation is what cracked rounds 4–6. Lesson: when a live-only bug resists reproduction, ship diagnostics, not guesses.
Round 4 — drag-move deletes with NO didChangeText (2026-07-02, 9f99795, PR #163)
- Symptom (from the round-3 trace): drifting deletes showed
shouldChangeText→ nothing →selectionDidChangemid-recompose with a stale caret. Origin event: a drag-select, thenshouldChangeText OK repl="", then LEN-MISMATCH forever —didChangeTextnever fired. - Root cause: AppKit's drag-move gesture, when the drop lands past the
end of the document (or fumbles), performs the source deletion via
shouldChangeText→replaceCharactersand never callsdidChangeText.rawSourcesilently froze — and autosave wrote the stale bytes: this was a data-corruption bug, not just a caret bug. - Fix:
shouldChangeTextschedules a next-run-loop bypass check (RunLoop.main.perform): apendingEditstill unconsumed one pass later == didChangeText was bypassed → run the same sync it would have, loghealing storage edit that bypassed didChangeText(release too). Exempt whileisUpdating/isUndoRedoing/hasMarkedText()(IME legitimately defers). - Why it came back: the heal restored the model but rounds 5–6 found the heal itself could move the caret.
Round 5 — the heal leaped the caret (stale selection) (2026-07-03, 422498f, docs c4a602b, merged 222dd86, PR #166)
- Symptom: heal fired, invariant restored, bytes correct — but the caret leaped to the END of the document at the heal moment.
- Root cause: when the bypassed deletion removes the selected text, AppKit also skips its usual selection fix, so at heal time the selection still spans deleted text (e.g. {951,37} in a 973-char doc). The heal's restyle makes AppKit re-resolve the invalid selection → clamps to document end.
- Fix: before syncing, the heal collapses an out-of-bounds selection to the edit point. (Headless NSTextView clamps this itself — the test documents intent; only live layout reproduces the leap.)
- Why it came back: this out-of-bounds clamp was a special case of the real mechanism, found in round 6.
Round 6 — TextKit 2's queued selection fixup: the drift mechanism itself (2026-07-04, 1b1420a, branch fix/wrapped-paragraph-caret-drift, merged 218d922, PR #169)
- Symptom: typing mid wrapped paragraph, one backspace leaped the caret +43 ("two viewport-lines down"); drift no longer continuous — one delete drifts, the next ones don't. Model fine; a heal had fired 80 seconds EARLIER.
- Root cause (named via a
traceSelectionOriginstack capture): a normal edit runs TextKit 2's_fixSelectionAfterChangeInCharacterRangesynchronously inside its own transaction. A didChangeText-bypassing mutation skips that too, so the fixup stays queued and fires at the NEXTendEditing— the heal's attribute-only restyle — where it maps the stale selection against post-edit coordinates and drops the caret blocks away. Fires exactly once (state is then synchronized), explaining "drifts once, then fine". Round 5's clamp was the sub-case where the stale selection ran past the shrunk document end. - Fix: the heal derives the correct caret from the pendingEdit hull and
sets it both before AND after
syncRawSourceFromDisplay(). The before-only version still leaped — the queued fixer moves even a freshly set, fully valid caret during the sync'sendEditing. The post-sync re-assert is the load-bearing half; the pre-set keepscursorRaw/active-block styling correct. - The breakthrough repro (first deterministic one in six rounds):
ReproScript.swift(DEBUG-only,-debug.reproScript <path>) replays keystrokes in-process throughwindow.sendEvent(_:)— no TCC, works on an invisible Space.bypassdelete <needle>simulates the drag-move deletion exactly; one bypass beforehand → the next delete always drifts. Typing alone never drifts. Seeedmund-live-repro-and-diagnostics.
Do not retry (proven dead across the saga)
- Headless/unit tests for this class. The test harness runs TextKit 2's selection fixups synchronously, so the deferred-fixup state never forms. The round-6 unit test passes with and without the fix — it is a contract spec, not a regression guard. Only the ReproScript live repro discriminates. Do not "add a test to catch it" and call the class covered.
- CGEvent injection as the default live driver. Round 6's session dropped the events (per-session TCC), and the app's windows launch on an inactive Space. Use ReproScript first.
- DEBUG assertion in
didChangeText's marked-text guard — rejected in round 1: the invariant is legitimately broken during composition; it false-fires on every IME keystroke. - "No explicit selection repair needed" (round 4's claim) — wrong twice. Any new heal-like path must handle selection explicitly, before and after.
swift buildtrusted after "Build complete!" — round 6 hit a stale-link relink failure TWICE; two "failed" fix iterations were phantoms running old code. Verify withstringson a LONG literal (≤15-byte literals inline on arm64 and never show), cure withswift package clean, never hand-deleteedmd.build/. Details:edmund-build-and-env.
2. Undo/redo viewport drift — the costliest failure
STATUS: settled (2026-07-02, 5bb2b40, part of PR #164) — with one caveat:
misc/backlog.md "Lurking (Unreproduceable)" carries a later note "Undo/Redo
and Copy/Paste scrolling is failing again". No repro exists. Treat the
mechanism below as settled and any new report as a NEW investigation that
starts from this entry.
- Symptom: undo scrolled too far down; redo centered on wherever the caret sat before the undo; changed text never selected.
- Root cause (two defects, found by code read before any experiment):
restoreSnapshotran a fullrecompose— replacing the entire storage discards every TextKit 2 layout fragment, resetting ALL geometry to height estimates; the subsequent centering math measured estimates.performUndorecorded the redo snapshot with the caret at undo invocation time (stale), and redo centered on it.
- Fix:
textDiff(old:new:)single contiguous changed span → range-boundedrecomposeReplacing(layout outside the span stays real); the changed range — never a stored caret — is selected and drives the viewport (hold if visible, else center). - Load-bearing contract: never full-recompose on undo/redo. Anyone
"simplifying"
restoreSnapshotback torecomposereintroduces the bug. Guarded byTextDiffTests,UndoRedoSelectionTests,UndoRedoViewportTests. - Prior art that treated symptoms without naming the estimate problem:
9aaa11b(undo hold-or-center),2778d6e/21cc284(cursor-move lurch),84123e4(pin scroll above viewport),c49cd5c(lazy viewport-first styling). All sound, all workarounds;5bb2b40removed the manufactured estimates at the source.
3. Viewport glitches — TextKit 2 height estimates (PR #164, 2026-07-02)
Full trail: docs/investigations/viewport-glitch-investigation.md. Three reported symptoms,
one root cause: every off-screen TextKit 2 frame is an estimate; code that
discards layout, trusts off-screen y, or runs two scroll policies at once turns
estimate churn into visible jumps. Community-documented (Krzyżanowski /
STTextView, Apple forums) — even TextEdit exhibits it.
- Bug 1 (undo/redo): entry 2 above. STATUS: settled (same caveat).
- Bug 2 (editing at top pushes line 1 above the viewport, can't scroll up).
Theory: TK2 assigns fragments negative y when estimates above the
viewport correct downward; scroller clamps at 0. Mitigations
217da5f(documents ≤100k UTF-16 kept fully laid out via a deferredscheduleFullLayoutSettle— estimates never exist) and8b4ecfe(repairContentAboveOrigin: first fragmentminY < -0.5→ re-lay start→viewport-end insidepreservingViewportAnchor; breadcrumbrepairing content above origin). STATUS: mitigated-unconfirmed. The doc is explicit: Bug 2 was never reproduced live; the repair had not been confirmed against a live occurrence as of this writing (2026-07-05). If it recurs: grep~/.edmund/logsfor the breadcrumb — present means diagnosis confirmed but repair raced/undersized; absent means different cause (scroller-only estimate jumps, ortextContainerOrigin). - Bug 3 (viewport oscillates during a steady drag-select). Two scrollers
fighting: drag autoscroll pulling down vs the
scrollRangeToVisibleoverride always revealing the selection top once the selection outgrew the viewport. Fix340fcbc: reveal the nearest end. STATUS: settled by geometry/reasoning — a live drag was never synthesized (that session couldn't arm AppKit selection). Phase 1 of the same report ("can't select") was NOT a bug: a whole-doc selection was active, so the drag was AppKit's drag-move gesture — same family as delete-drift round 4. - Do not retry: raising
fullLayoutMaxLengthwithout measuringensureLayoutcost (full layout on large docs is the process-killing path that motivated the lazy pipeline); running a full layout inside a caller'spreservingViewportAnchor(poisons its before/after measurement — the tab-indent stability test caught a 366pt compensation; that's why the settle is deferred). - Backlog keeps "Inaccurate viewport estimates and things related" under On-going bugs, plus a lurking "glitch when scrolling" — the estimate class is managed, not extinct. Roadmap v1.0.0 carries "TextKit 2 viewport stabilization".
4. Callout-title wrap / the image wedge (settled 2026-07-03, aa45563 + ae61644, PR #165)
Full trail: docs/investigations/archives/callout-title-wrap-investigation.md — including a 10-row
matrix of dead ends.
- Symptom/goal: custom callout titles (
> [!type] Title) should render as real wrapping text with the type icon; any attempt to draw the icon clipped the wrapped title to one line. - Root cause: drawing any IMAGE on a multi-line layout fragment wedges
that fragment to a single line — an unexplained TextKit 2 reentrancy quirk.
Isolated exhaustively: fragment overlay, frame-relative draw, before/after
super.draw, rawCGContext.draw, editor-leveldraw(_:), pre-rasterized bitmap, transparent subview, layer-backed subview, CALayercontents— ALL clip. Controls: no icon → wraps; positions computed but plain rect filled instead of the image → wraps. Reading layout is fine; drawing a shape is fine; drawing an image is not. - Fix: the icon is a stroked
CGPath—SVGPathparses the vendored Lucide geometry,FragmentOverlaygained a path form,DecoratedTextLayoutFragmentstrokes it in CG. Verified live: icon renders, long titles wrap and re-wrap on resize. - Standing constraint: any future overlay that can share a line with wrapping text MUST use the path form, never the image form. Existing image overlays (math, bullets, default callout header) survive only because they sit on single-line fragments.
- Do not retry: any image-drawing mechanism from the matrix; bumping the
deployment target to macOS 15 on the hope newer TextKit 2 fixed it (no
evidence, drops Sonoma incl. the dev machine). The whole-header-as-image
alternative is preserved on branch
fix/callout-title-image(tipc7f5170, unmerged): it sidesteps the wedge but has two unsolved problems (~2× line height band above the title; a width-timing race). - Still open nearby (backlog): callout icon baseline / crispness; the callout-at-end-of-file extra colored line (live-path rendering bug).
5. The 0.1.0 release failures (2026-06-26 → 2026-07-02)
Reference: ARCHITECTURE §13. Five separate failures shipping and updating the first releases. STATUS: all settled, with one time bomb (PAT expiry).
sign_update -sexits 1 for new keys (59565f5, 2026-06-27, PR #135). Sparkle deprecated-s <key>; for keys generated after that change it prints a warning and exits 1. Killed the first 0.1.0 release. Fix: key on stdin —echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>. Do not retry-s.- Bundle not sealed → EVERY update failed "improperly signed"
(
5e54b40, 2026-06-29, issue #158). Sparkle re-validates the Apple code signature at install (SUUpdateValidator→SecStaticCodeCheckValidity); the build signed only the main binary, never sealed the bundle, so a valid EdDSA signature didn't save it. Fix:codesign --deepthe whole.app— and because SwiftMath's resource bundle must sit at the.approot (Bundle.modulehardcodesBundle.main.bundleURL) and codesign won't seal a bundle with root items, seal first, copy the SwiftMath bundle in AFTER sealing. The lone unsealed root item trips strictcodesign --verifybut not Sparkle's non-strict check (verified against that exact API). Do not "fix" the ordering or the failing strict verify. - Appcast push to protected
mainrejected,GH006(e56a4dd, 2026-06-28, PR #140).GITHUB_TOKENisn't admin;enforce_admins: falsemeans an admin PAT bypasses the required check. Fix: fine-grained admin PAT in secretRELEASE_TOKEN, set on the checkout step (not the push URL —actions/checkoutpersists anextraheadercredential that overrides inline-URL creds).RELEASE_TOKENexpires 2027-06-27; rotate before then or releases fail at the appcast push. - create-dmg quirks (
098d8c0, 2026-06-26, documented in §8): it's the npm create-dmg (sindresorhus), not the Homebrew tool; Node ≥20; exit code 2 for unsigned images is success; space-in-filename normalization. - Release workflow YAML invalid (
854f85d, 2026-07-02, merged232e6c8, PR #162). Literal multi-line bash strings inside arun: |block had unindented lines — invalid in a YAML block scalar; the whole workflow file failed to parse. Fix: build the strings withprintf\nescapes; also$(...)strips trailing newlines, so the separator newline lives inNEW_ITEM's format string, notDESC_BLOCK.
6. Reverts and abandoned directions
- Selection tint —
ee173f7(2026-06-26): reverted an experimental selection color back to accent-derived (accent @ 30% alpha). STATUS: settled. Don't re-hardcode a bespoke selection color. img.md-image { display:block }in the export/Read HTML theme —75d2824(2026-06-25): reverted; it did NOT fix the image blank-space and broke layout. The commit title itself records the verdict: image blank-space is a separate, STILL-OPEN bug (backlog: "Attached image padding… creates a large empty space below",misc/bug-repros/image-blank-after.mov). Do not retry display:block for it.- Checkbox click-to-toggle + table borders —
9991413(2026-06-03): pulled from a branch for separate passes. STATUS: partially redone. Table work landed later (edit-mode table alignment shipped, per backlog Done); Read-mode click-to-toggle checkbox is still in the v1.x backlog — reverted-pending-redo. The original attempt lives on stale branchfeature/checkbox-toggle(merged history,d6227e3). - TextKit 2 migration regressions —
b3a4b29(2026-06-12): the TK1→TK2 migration silently broke inline-math height, HR spacing, and scroll; fixed withRenderingRegressionTestsas the guard. Lesson: TK2 migrations regress silently in geometry — extend that suite when touching layout. - Stale branches that look abandoned but are MERGED (don't "rescue" them):
feature/incremental-recompose(tip1222f79, in main) andrefactor/word-level-rendering(merged via PR #8,7eb6a21— word-level delimiter hiding became the shipped approach). The genuinely unmerged WIP branches arefix/callout-title-image(entry 4) and dozens of old merged topic branches never deleted.
7. Wrapped-paragraph caret drift (PR #169) — same battle as round 6
fix/wrapped-paragraph-caret-drift / merge 218d922 IS delete-drift round 6
(entry 1): the branch name comes from the reporting symptom (backspace mid
wrapped paragraph), but the root cause was the queued TextKit 2 selection
fixup armed by an earlier bypassed drag-move edit — the wrapped paragraph was
incidental. STATUS: settled with 1b1420a. If a caret drift is reported
"in a wrapped paragraph", do not assume wrapping is the mechanism; check for a
preceding heal breadcrumb in ~/.edmund/logs first.
8. Smaller settled battles (one paragraph each; verified in git)
- Flaky math fit-width test — took TWO rounds (
54212972026-06-06 branchfix/flaky-math-test, then352bdc9PR #65). First fix pinned the text container width (tracking off) — the flake persisted ~1 in 3 runs. Real cause: shared theme-defaults state pollution between tests; fixed with isolated defaults. Lesson: a flake "fix" that doesn't name the shared state isn't done. - Emoji rendered as missing-glyph boxes (
0f5ffba, 2026-06-06).EditorTextStorage.fixAttributesis a deliberate no-op (so.attachmenton real characters survives) — which also disabled font substitution. Fix: perform substitution manually (Apple Color Emoji per composed-character sequence, ZWJ/skin-tone graphemes whole). Don't re-enable the frameworkfixAttributes; it strips the marker attachments. - Nested list hanging indent (
8d2088f, 2026-06-06, PR #64). swift-markdown's list-item delimiter excludes leading indentation; the visible spaces broke the hang. Fix: hide the leading whitespace in the inactive branch; indentation comes entirely from the paragraph style. - Ordered-list deep nesting lost styling (
b255903, 2026-06-06). swift-markdown parses ≥4-space indent as indented code; the rescue regex only matched[-*+]. Extended to\d{1,9}[.)]. Any new list-ish syntax must be added to the rescue parser too. - Window size persistence — three commits to get right (
538ff6e→d967bcf→678c5d6, 2026-06-28, PR #144). Saving content-view size made windows grow taller on reopen (titlebar double-counted); final form stores the full window frame and restores viasetFrame. - Toolbar right-click interception — three failed view-level attempts
(
4eb604a,6723280, thenf8472ca, 2026-06-26). View.menu,rightMouseDown, and a gesture recognizer all lost to the toolbar's "Customize Toolbar…" menu. Working fix:DocumentWindowoverridesNSWindow.sendEvent— the documented funnel ahead of the toolbar — and swallows secondary clicks on the view-mode button. Do not retry view-level interception for anything the titlebar/toolbar claims. - Invisible CJK input (
a3df387): IME-composed text was invisible until committed; fixed by keeping marked text visible. Related to (and predating) the round-1 marked-text rules.
9. Still OPEN — do not let this chronicle imply otherwise
Per misc/backlog.md (cross-checked 2026-07-05): footnotes don't render
(edit or Read); math doesn't render in Read mode; math padding in edit mode;
image blank-space below attached images (see the 75d2824 revert);
callout-at-end-of-file extra colored line; max content width not applied to
Read mode; tables don't wrap/shrink at small content sizes; table-cell content
wraps out of the cell; "sometimes click to select / select+delete doesn't
work" (unreproduced); lurking scroll glitch from off-viewport height changes;
lurking indented-cursor-stuck report; lurking "undo/redo and copy/paste
scrolling failing again" (see entry 2 caveat). Delete-caret-drift and
viewport-estimate classes stay on the watch list even though every known
mechanism is fixed.
Provenance and maintenance
- Written 2026-07-05 by mining:
docs/investigations/delete-drift-investigation.md,docs/investigations/viewport-glitch-investigation.md,docs/investigations/archives/callout-title-wrap-investigation.md,docs/ARCHITECTURE.md§13,CHANGELOG.md,misc/backlog.md,docs/ROADMAP.md, andgit log --all(every hash above verified withgit showon that date). - Code and git win over prose. If this file disagrees with a commit or the current source, trust the commit, then fix this file.
- Update triggers: a new delete-drift round (append to entry 1 — never a
new doc); any live confirmation or refutation of
repairContentAboveOrigin(flip entry 3 Bug 2 off mitigated-unconfirmed);RELEASE_TOKENrotation (entry 5); any revert (entry 6); closing a backlog bug named in entry 9. - Keep the status vocabulary exact; "mitigated-unconfirmed" is not "fixed". No oversell — this file's value is that its claims can be trusted blind.
- Sibling map lives in "When NOT to use" above; keep it in sync as the skill library grows.
.claude/skills/edmund-live-repro-and-diagnostics/SKILL.md
npx skills add I7T5/Edmund --skill edmund-live-repro-and-diagnostics -g -y
SKILL.md
Frontmatter
{
"name": "edmund-live-repro-and-diagnostics",
"description": "How to MEASURE Edmund instead of eyeballing it — the diagnostic tools, interpretation guides, and working scripts. Load when a bug involves LIVE behavior (caret, IME, drag, viewport timing), when a unit test cannot reproduce a report, when you need to read diagnostic traces, drive the running app with scripted keystrokes, or measure pixels from a screenshot. Contains the repro escalation ladder, the ReproScript driver, the CGEvent fallback, and screencapture measurement, plus scripts\/ helpers. Not the symptom→mechanism table (edmund-debugging-playbook), not the campaign (edmund-caret-integrity-campaign), not build hygiene (edmund-build-and-env)."
}
Edmund live repro & diagnostics
A class of Edmund bugs lives in the live NSTextView / TextKit 2 / input-context
layer (deferred selection fixups, IME composition, drag sessions, event-loop
timing). Headless tests cannot form the broken state — the test harness runs
AppKit's deferred machinery synchronously, so a green unit test proves nothing
about this class. This skill is how you make such a bug cheap to observe, then
deterministic. Primary source doc: docs/dev-guides/live-repro-guide.md.
Verified 2026-07-05.
0. Safety preamble (do this every time)
- Check for the user's live instance first. The user's daily-driver app has
the same binary name (
edmd). Runscripts/check-live-instance.sh. Never blanketpkill -x edmd— kill only your own PID, or usepkill -f EdmundDbg(only your debug bundle matches). - Recreate the test document fresh before every run — autosave mutates it; run 2 against run 1's leftovers produces garbage offsets.
- Verify the binary is fresh before trusting a run (SwiftPM sometimes prints
Build complete!without relinkingedmd) — strings/shasum method in edmund-build-and-env. - Do not request macOS Computer Access. Screen Recording + Accessibility are already granted for this project; ReproScript (§3) needs neither.
1. The escalation ladder
Work down; stop at the first level that reproduces. Each is more faithful and more expensive than the one above.
| # | Technique | Faithful to | Cost | Use when |
|---|---|---|---|---|
| 1 | Plain unit test (makeEditor()) |
model/parse/style logic | seconds | anything not event-timing-dependent |
| 2 | Windowed unit test (NSWindow + NSScrollView, real deleteBackward(nil)) |
+ layout, viewport, first responder | seconds | viewport/lazy-styling bugs (LazyRenderingTests setup) |
| 3 | In-process ReproScript (§3) | + real key path, run-loop pacing, real process | ~1 min/run | anything keyboard/edit-pipeline shaped — the default for live bugs |
| 4 | CGEvent driver (§4) | + real HID events, real mouse (drags!) | TCC-dependent | mouse-only paths: drag-select, drag-move, autoscroll |
| 5 | Instrumented field occurrence | everything | days | can't trigger it — instrument first (§2), decide on the next hit |
Two levels deserve emphasis:
- Level 2 failing to repro is evidence, not defeat — it tells you the bug needs deferred/queued AppKit state, pointing you at level 3–4.
- Level 3 exists because level 4 is unreliable — background/agent sessions often have no TCC grant and synthetic keyboard events get dropped silently. In-process injection needs no permission.
2. Step 0 — make the trace tell you the trigger
Never script blind. The recipe is usually already in ~/.edmund/logs, if
verbose diagnostics were on. Launch flags (file arg must be argv[1]):
<app>/Contents/MacOS/edmd FILE.md \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES
Trace-field decoder (each verbose line carries these; from
EditorTextView+Diagnostics.swift):
| Field | Meaning |
|---|---|
sel |
current selection {location,length} |
active |
active (caret) block index |
marked |
is there marked/IME text |
up |
isUpdating — up=Y = event arrived mid-recompose (suspicious) |
undo |
undo-stack depth |
blocks |
block count |
storLen / rawLen |
storage length vs rawSource length |
- Healthy edit ordering:
shouldChangeText→selectionDidChange(up=N) →synced. A transient⚠︎LEN-MISMATCHbetween those lines is normal (storage moves before rawSource syncs). - Suspect: a
selectionDidChangewithup=Yat a surprising position; a persisting LEN-MISMATCH; ashouldChangeTextwith nosynced/SKIPPED/DEFERREDafter it (a bypasseddidChangeText); thehealing storage edit that bypassed didChangeTextbreadcrumb. traceSelectionOriginlogs a call stack for any selection change that lands mid-recompose — this is what named_fixSelectionAfterChangein round 6.- Walk BACKWARDS from the first bad line, not forwards from the symptom. The round-6 drift was armed ~80 seconds and dozens of healthy edits before the visible failure. The user-visible symptom is often the second half.
If the log didn't capture the deciding fact, add the log line first (keep
good ones behind Log.shouldTrace and ship them) and reproduce again. Also
reconstruct the document — wrapped-paragraph geometry, block boundaries, and
block kinds all matter; repro against a lookalike, never "hello world".
scripts/grep-trace.sh [YYYY-MM-DD] surfaces the suspect patterns in one shot.
3. The in-process ReproScript driver (default for live bugs)
Sources/edmd/App/ReproScript.swift, DEBUG builds only. Replays a keystroke
script against the front document by synthesizing NSEvents and pushing them
through window.sendEvent(_:) — the full authentic key route (keyDown →
interpretKeyEvents → insertText: / deleteBackward:). No Accessibility, no
visible window required (works on an inactive Space), real run-loop pacing.
Launch: scripts/launch-debug.sh FILE.md SCRIPT.repro (assembles EdmundDbg.app,
guards the user's instance, direct-execs with all flags). Or by hand:
build/EdmundDbg.app/Contents/MacOS/edmd "$DOC.md" \
-settings.general.diagnosticLogging YES \
-settings.advanced.verboseEditorDiagnostics YES \
-debug.reproScript "$SCRIPT.repro" \
-ApplePersistenceIgnoreState YES &
Command surface (one per line, # comments allowed):
| Command | Effect |
|---|---|
sleep <ms> |
wait before the next command |
caret <needle> |
place caret before the first occurrence of <needle> |
type <text> |
one real key event per char, ~80 ms apart |
backspace <n> |
n real delete keystrokes, ~300 ms apart |
bypassdelete <needle> |
simulate the drag-move source deletion: select range, shouldChangeText + storage mutation, no didChangeText |
assertcaret <needle> |
log repro assertcaret PASS/FAIL sel=… want=… iff caret sits exactly before <needle> |
logsel |
log selection, rawSource length, doc count |
Round-6 minimal repro (the worked example): the deciding output was logsel
321 (broken) → 290 (fixed), every run, window not even visible.
sleep 2000
bypassdelete Sizemore,
sleep 800
logsel # broken: {321,…}; fixed: {290,…}
backspace 2
logsel
Design rules — keep them when extending:
- Address text by needle, never offset — offsets go stale the moment a script edits; needles survive (this is what makes soak scripts possible).
- Real events over direct method calls —
insertText("")shortcuts skipdeleteBackward's selection machinery, the exact place round 6 lived. - Simulate AppKit-internal paths by exact call sequence —
bypassdeletereplicatesshouldChangeText→replaceCharacters, nodidChangeTextverbatim, not an approximation. Pin any new internal path's real sequence from atraceSelectionOriginstack first, then replay it. - Asserts inside the app, results in the log — the harness (you, or a shell
loop) only greps
PASS/FAIL; the app is the oracle. - New commands are ~10 lines each — extend
ReproScript.swift, don't work around it.
Soak scripts (§6): chain several trigger cycles at different positions with
ordinary editing between them, assertcaret after each predictable step, and
compare final rawLen across runs (byte-identical = deterministic). A soak green
across 4–5 cycles is far stronger than one clean repro — it catches bugs needing
armed state (round 6's queued fixup).
bypassdelete Sizemore,
assertcaret Strang
backspace 2
type xy
bypassdelete widely
assertcaret used in various
logsel
4. CGEvent driver (mouse-only paths, TCC willing)
For paths that must originate as HID events — real drag-select, drag-move,
autoscroll — keyboard replay can't cover them. A ~70-line ui.swift (compile
with swiftc) posting CGEvents does: bounds <substr> (window lookup via
CGWindowListCopyWindowInfo), click x y, dragselect, dragmove (mousedown +
~400 ms hold before moving, or AppKit never arms the text drag), key,
type.
Caveats (all hit in practice):
- TCC decides per session. Background/agent sessions often can't post
keyboard events (dropped silently) or use System Events. Test with **one click
- log check**; if input doesn't land, fall back to §3 immediately — don't iterate on driver variations.
- App windows are on an inactive Space until activated
(
kCGWindowIsOnscreen == false);osascript -e 'tell application "<path>.app" to activate'(Apple Events, a separate TCC bucket) may work where System Events is denied. - Re-activate before every interaction batch; focus is lost between shell calls.
5. Screencapture measurement
Visual judgments are measured, not eyeballed — when the task says "balance padding" or "align the icon", capture the window and measure pixels.
scripts/capture-window.sh <window-title-substring> out.png finds the window id
(JXA → CGWindowListCopyWindowInfo) and runs screencapture -x -o -l<id>.
Notes:
- Capture by window id, reliable even when not frontmost.
- Crop by the detected window bounds — the desktop wallpaper defeats screencapture's brightness-based auto-crop.
- Measure padding/alignment from the PNG (e.g. a short Python/PIL pixel scan for the first/last colored row of a callout box). Report the pixel numbers, not an impression.
- Window-server state can glitch (tiny windows, restoration) after many rapid
launch/kill cycles:
rm -rf ~/Library/"Saved Application State"/com.i7t5.edmund.savedStateand relaunch.
6. The loop, end to end
- Verbose trace from the occurrence → find the first bad line, walk backwards, form a trigger hypothesis (§2).
- Reconstruct the document; script the hypothesized trigger (§3).
- No repro? Hypothesis wrong or fidelity too low — move down the ladder (§1), or instrument and wait for the next hit.
- Repro in hand? Freeze it (exact script + document), then let it falsify fix candidates — round 6's first "fix by reasoning" failed in the repro within a minute.
- Fix verified → soak (§3) → full
swift test→ keep the script + new diagnostics → update the relevantdocs/*-investigation.md.
Meta-lesson from six rounds: time spent making the failure cheap to observe beats time spent reasoning about the fix. Every round that shipped on reasoning alone came back; the round that shipped on a deterministic repro named the actual mechanism.
scripts/ (in this skill dir)
| Script | Purpose | Status |
|---|---|---|
check-live-instance.sh |
Report running edmd, exit 2 if any; never kills |
logic verified; safe by construction |
grep-trace.sh [date] |
Surface suspect patterns in today's log | logic verified |
capture-window.sh <needle> <out.png> |
Screenshot a window by id + report bounds | verify on first use (JXA CGWindowList lookup not executed this session) |
launch-debug.sh <file.md> [script.repro] |
Build + assemble EdmundDbg.app + direct-exec with flags | verify on first use (assumes arm64 debug triple; guards user instance) |
All four pass bash -n. The two "verify on first use" scripts depend on live
system state (window server, build layout) that couldn't be exercised while
authoring; read the header comment before first run.
When NOT to use this skill
- Deciding which mechanism a symptom implies → edmund-debugging-playbook.
- Running the full caret-integrity investigation → edmund-caret-integrity-campaign.
- Stale-binary detection / bundle internals → edmund-build-and-env.
- What counts as sufficient evidence to ship → edmund-validation-and-qa.
- The AppKit theory behind the fixup/marked-text mechanisms → textkit2-appkit-reference.
Provenance and maintenance
Verified 2026-07-05 against docs/dev-guides/live-repro-guide.md,
Sources/edmd/App/ReproScript.swift, and
Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift.
grep -oiE '"(sleep|caret|type|backspace|bypassdelete|assertcaret|logsel)"' Sources/edmd/App/ReproScript.swift | sort -u
grep -n 'debug.reproScript' Sources/edmd/App/ReproScript.swift
grep -rn 'traceSelectionOrigin\|LEN-MISMATCH\|shouldTrace' Sources/EdmundCore/TextView/EditorTextView+Diagnostics.swift Sources/EdmundCore/Diagnostics/Log.swift
grep -n 'healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
Re-verify the scripts against docs/dev-guides/live-repro-guide.md §4 if the debug-bundle
assembly recipe changes.
.claude/skills/edmund-release-and-operate/SKILL.md
npx skills add I7T5/Edmund --skill edmund-release-and-operate -g -y
SKILL.md
Frontmatter
{
"name": "edmund-release-and-operate",
"description": "Load when cutting or debugging an Edmund release, or operating the shipped app. Triggers: version bump (Info.plist CFBundleShortVersionString \/ CFBundleVersion), tagging vX.Y.Z, CHANGELOG.md release sections, release.yml \/ release.sh \/ build-app.sh, appcast.xml or Sparkle update failures (\"improperly signed\", update never offered), sign_update \/ EdDSA keys \/ SPARKLE_ED_PRIVATE_KEY \/ RELEASE_TOKEN, create-dmg or DMG naming problems, Gatekeeper \"damaged\" reports, launching the built app, reading ~\/.edmund\/logs, crash reports (edmd-*.ips), or roadmap\/priority questions."
}
Edmund — release & operate
Date-stamped 2026-07-05. Verified against .github/workflows/release.yml,
scripts/release.sh, scripts/build-app.sh, scripts/changelog-to-html.py,
appcast.xml, Info.plist, CHANGELOG.md, docs/ARCHITECTURE.md §8/§13, and
the Settings/CrashReporter sources. Where a doc and a script disagree, the
script is the truth; disagreements are flagged inline.
House rule: releases happen only when the maintainer explicitly asks.
Never tag, push, create a release, or merge on your own initiative — see
edmund-change-control. Everything in §1–§4 below is a runbook for when the
maintainer says "cut a release", not a standing instruction.
When NOT to use this skill
| You actually need | Go to |
|---|---|
| Build/test commands, stale-build cures, launch mechanics in depth | edmund-build-and-env |
| Editing invariants, render pipeline, TextKit 2 rules | edmund-architecture-contract, textkit2-appkit-reference |
| Debugging a bug in the app itself | edmund-debugging-playbook, edmund-live-repro-and-diagnostics |
| Past incidents and why the sharp edges below exist | edmund-failure-archaeology |
| Debug flags / launch arguments | edmund-config-and-flags |
| Branch/commit/PR etiquette, what needs maintainer sign-off | edmund-change-control |
| Pre-merge QA method | edmund-validation-and-qa |
| README/website/positioning copy | edmund-docs-and-writing, edmund-external-positioning |
1. Release flow — CI path (the normal one)
Ship via a tag; CI does the rest. In order:
- Bump versions in
Info.plist— both keys:CFBundleShortVersionString— marketing version, e.g.0.1.3CFBundleVersion— build number, monotonic integer (0.1.3 =4)
- Add a
## [x.y.z]section toCHANGELOG.md— format is load-bearing, see §2. The version MUST match Info.plist exactly. - Merge to
mainand push (via the normal PR flow). - Tag and push the tag:
git tag vX.Y.Z && git push origin vX.Y.Z - CI (
.github/workflows/release.yml, triggerpush: tags: 'v*', runnermacos-14, jobrelease/ "Build & publish") runs the steps below. - Afterwards, verify per §4 post-flight.
release.yml step anatomy (actual step names)
| Step | What it does | Sharp edge |
|---|---|---|
actions/checkout@v5 |
fetch-depth: 0, token: ${{ secrets.RELEASE_TOKEN }} |
The PAT must be on this step — see §3.4 |
maxim-lobanov/setup-xcode@v1 |
latest-stable Xcode | |
| Cache .build | SPM cache keyed on Package.resolved |
|
| Build app bundle | ./scripts/build-app.sh — release build, bundle assembly, Sparkle embed, bundle sealing |
§3.2 |
actions/setup-node@v4 |
pins Node 20 | create-dmg 8.x needs Node ≥ 20 |
| Install create-dmg | npm install --global create-dmg (sindresorhus/create-dmg, not Homebrew's) |
§3.5 |
| Create DMG | reads VERSION from Info.plist, create-dmg build/Edmund.app build/ || true, renames "Edmund <v>.dmg" → Edmund-<v>.dmg, fails loudly if no dmg |
§3.5 |
| Sign archive (EdDSA) | finds sign_update in .build, key on stdin via --ed-key-file -, exports ED_SIG and LENGTH |
§3.1 |
| Create GitHub Release | awk-extracts the CHANGELOG section → gh release create "v${VERSION}" build/Edmund-${VERSION}.dmg --title "Edmund ${VERSION}" --notes-file … --latest |
§2 |
| Update appcast.xml | builds the new <item> (HTML <description> via scripts/changelog-to-html.py), inserts it before </channel>, commits as github-actions[bot], git push origin HEAD:main |
§3.4 |
Local path (scripts/release.sh)
Mirrors CI: build-app.sh → create-dmg (+ rename) → EdDSA sign → update
appcast.xml locally → gh release create. Two differences:
- Signing: with
SPARKLE_ED_PRIVATE_KEYin the env it uses the stdin path (CI-style); otherwisesign_updatepulls the key from the login keychain (put there by Sparkle'sgenerate_keys) with no flag at all. - The appcast commit/push is left to you. The script ends with the exact
commands:
git add appcast.xml && git commit -m 'Release <v>' && git push.
Prereqs for the local path: gh auth status authenticated, npm create-dmg
installed, swift build has run at least once (so sign_update exists under
.build).
Stale doc:
misc/how-to-release.mdstill says the artifact is a zip ("signs the zip", "Zip it to build/Edmund-1.0.zip"). That predates the DMG switch. The truth is DMG throughout — perrelease.yml,release.sh, and ARCHITECTURE §13. Trust the scripts, and fix that doc when touching it.
2. CHANGELOG format contract (release notes are machine-extracted)
Both release.yml and release.sh extract the GitHub Release body with:
awk "BEGIN{p=0} /^## \[${VERSION}\]/{p=1;next} p && /^## \[/{exit} p{print}" CHANGELOG.md
So the section header must start at column 0 as ## [x.y.z] — literally
## [0.1.3] — 2026-07-04 in house style (em dash + ISO date after the
bracket is fine; the match only requires the ^## \[x.y.z\] prefix).
Extraction runs until the next ^## [ line. If nothing matches, the release
body falls back to "See CHANGELOG for details." — a silent-ish failure, so get
the header right. (Version dots are unescaped in the regex; harmless in
practice, don't rely on it.)
The Sparkle update-dialog notes come from the same section via
scripts/changelog-to-html.py <version>, a deliberately tiny converter that
only understands Keep-a-Changelog shapes:
### Added/### Changed/### Fixed→<h3>— use###, not##. The 0.1.2 appcast item literally shows<p>## Changed</p>because the section used##subheads at release time; the converter passed them through as paragraphs.-/*bullets →<ul><li>; indented continuation lines fold into the previous bullet.`code`and**bold**are converted. Markdown links are NOT —[docs](docs/foo.md)appears literally in the update dialog (see the 0.1.2 item). Keep appcast-facing notes link-free or accept the raw brackets.- Blank lines and
---are skipped; anything else becomes a<p>. - Missing section → empty output → the
<description>is simply omitted.
House format (verified from CHANGELOG.md): Keep a Changelog 1.1.0 + SemVer,
newest first, sections separated by ---.
3. The sharp edges (each one killed or nearly killed a real release)
3.1 sign_update -s is FATAL — key goes on stdin
Sparkle deprecated -s <key>; for newly generated keys it prints a
deprecation warning and exits 1 ("no longer supported"). This killed the
first 0.1.0 release. The only correct invocation with a key-in-hand:
echo "$SPARKLE_ED_PRIVATE_KEY" | sign_update --ed-key-file - <dmg>
Both release.yml and release.sh do exactly this. Never "simplify" it back
to -s. Output format: sparkle:edSignature="<sig>" length="<n>" — the
scripts grep those two attributes out for the appcast item.
3.2 Bundle sealing (the "improperly signed" update failure)
At install time Sparkle re-validates the update's Apple code signature
(SUUpdateValidator), independent of the EdDSA signature. A bundle that is
code-signed but not sealed (no _CodeSignature/CodeResources) fails that
check and every update dies with "The update is improperly signed and could
not be validated" — which is exactly what broke the v0.1.0 → 0.1.1 update
when the old script signed only the bare binary.
build-app.sh therefore signs inside-out and in a very deliberate order:
codesign --force --deep --sign - Sparkle.framework(nested XPC helpers must be signed before macOS will launch them),codesign --force --deep --sign - --identifier "com.i7t5.edmd"on the whole.appwhile its root holds onlyContents/— codesign refuses to seal a bundle with extra items at the root,- copy the SwiftMath resource bundle to the
.approot after sealing (its generatedBundle.modulelooks atBundle.main.bundleURL; without it the app crashes on the first LaTeX render).
Consequence: codesign --verify (CLI) and --strict will complain about
that one unsealed root item. That is expected and fine — Sparkle's actual
check is non-strict (SecStaticCodeCheckValidityWithErrors with
kSecCSCheckAllArchitectures) and tolerates it; verified end-to-end against
that API. Do not "fix" the verify warning by moving the SwiftMath bundle or
re-signing after the copy.
3.3 Keypair discipline
One EdDSA keypair, three places, all of which must agree:
| Place | Used by |
|---|---|
Info.plist SUPublicEDKey (0XdLbbuO…) |
Every shipped app, to verify updates |
| Maintainer's login keychain | release.sh local signing (no flag) |
GitHub secret SPARKLE_ED_PRIVATE_KEY |
CI signing |
If the signing key and SUPublicEDKey diverge, everything looks fine — the
DMG signs without error — but every user's update fails signature
verification. Sanity check when in doubt:
sign_update --verify <dmg> <sig> against the Info.plist public key.
3.4 Appcast push to protected main — RELEASE_TOKEN
The workflow's last step commits appcast.xml and pushes to main, which
requires the test status check. The default GITHUB_TOKEN /
github-actions[bot] is not an admin, so that push is rejected with
GH006 … protected branch hook declined. Branch protection has
enforce_admins: false, so an admin's push bypasses the check — hence the
fine-grained admin PAT in the RELEASE_TOKEN secret (Contents:
read/write), set as the token: on the checkout step, not on the push.
That placement matters: actions/checkout persists an
http.<host>.extraheader credential that overrides inline-URL credentials,
so rewriting the push URL would keep pushing with the bot token anyway.
RELEASE_TOKEN expires 2027-06-27. Rotate it before then or every
release fails at the appcast push while the GitHub Release itself succeeds
(a confusing half-shipped state — see §4 post-flight).
3.5 create-dmg quirks
- It's the npm package
create-dmg(sindresorhus), installed vianpm install --global create-dmg. Homebrew'screate-dmgis a different tool with an incompatible CLI. Requires Node ≥ 20 (CI pins it). - It exits 2 when it can't Developer-ID-sign/notarize the image (Edmund
ships ad-hoc) but still produces the .dmg. Both scripts run it with
|| trueand then verify the file exists, failing loudly only if no dmg was produced. Don't remove the|| true; don't trust the exit code. - Output is named
"Edmund <version>.dmg"— with a space. Both scripts rename toEdmund-<version>.dmg(hyphen), which is the name the appcast enclosure URL expects. If a rename is skipped, the release asset URL 404s for every updater.
4. Pre-flight and post-flight
Pre-flight (distilled from misc/before-you-release.md — read it too)
-
swift testgreen onmain, not just the branch;git statusclean. - No debug flags / launch args left on (repro drivers, verbose tracing —
see
edmund-config-and-flags, ARCHITECTURE §8). - Visual sanity: build and screencapture the editor in light and dark mode; click through everything the CHANGELOG claims ("fixed X" → actually reproduce X and confirm).
-
CHANGELOG.mdhas## [x.y.z] — YYYY-MM-DDfor this release and the version matches Info.plist (CFBundleShortVersionString);###subheads, not##(§2). -
CFBundleVersionbumped (monotonic int). -
RELEASE_TOKENnot expired (2027-06-27). - Local path only:
gh auth statusok; keychain key matchesSUPublicEDKey(§3.3).
Post-flight
- GitHub Release
vX.Y.Zexists with the right notes and theEdmund-<v>.dmgasset (hyphenated name). -
appcast.xmlonmaingot the new<item>— with<description>, correctsparkle:version(= CFBundleVersion) and enclosure URL. - Nothing to do for user prompts: Sparkle checks roughly daily; existing users see the update within ~24 h. Don't panic if it isn't instant.
If the Release exists but the appcast commit is missing, the release
half-shipped (usually §3.4). Fix the token, then add the <item> manually or
re-run the job.
5. Gatekeeper story (why users see "damaged")
Edmund is ad-hoc signed, not notarized (no $99/yr Developer ID). First launch of a downloaded copy trips Gatekeeper with the "app is damaged" dialog. This is expected; the app is fine. The README documents both workarounds (verified, README ~line 53):
xattr -dr com.apple.quarantine /Applications/Edmund.app, or- right-click → Open.
Known upgrade path (open/candidate, not scheduled): Developer ID certificate
- notarization would remove the prompt entirely and also clean up the non-strict-sealing compromise in §3.2. Don't promise it in user-facing text.
6. Operating the app
Launching
open Edmund.app foregrounds a running instance instead of relaunching —
you'll stare at old code. And never pkill -x edmd blindly: the maintainer's
own Edmund session may be running (the Mach-O is edmd for both). Check
first (pgrep -x edmd), kill only PIDs you started, or launch the binary
directly: build/Edmund.app/Contents/MacOS/edmd file.md &. Full launch /
stale-build / screencapture mechanics: edmund-build-and-env.
Logs — ~/.edmund/logs/edmund-YYYY-MM-DD.log
- One file per day, human-readable lines tagged
LEVEL [category](categories: app, document, io, render, compose, selection, lazy, callout, edit — grep by concern). - Controlled by Settings ▸ Advanced ▸ Diagnostics ("Save diagnostic
logs"). The toggle defaults OFF (
AppSettings.diagnosticLoggingdefaults false) — i.e. opt-in in the shipped app, despiteLog.swift's header comment calling it "always-on (opt-out)"; the code is the truth. (The UserDefaults keys are namedsettings.general.*for legacy reasons; the UI lives in Advanced.) - Retention picker ("Clear logs after:") next to the toggle; a separate
"Verbose editor tracing" opt-in gates keystroke-level trace lines — leave
off except during repros (
edmund-live-repro-and-diagnostics). - Release builds write
infoand up; DEBUG builds also writedebug. - Logs may contain document text; they never leave the machine.
Crash reports — ~/Library/Logs/DiagnosticReports/edmd-*.ips
- macOS names crash reports after the Mach-O executable: look for
edmd-<timestamp>.ips, not "Edmund-…". - Uploading is opt-in and currently INERT. The Settings toggle is
commented out in
Sources/edmd/Settings/AdvancedSettingsView.swift("dormant until the receiving server exists"), andCrashReporter.reportingEndpointis a placeholder (https://REPLACE-ME.invalid/crash). Nothing is ever sent in shipped builds. Don't tell users crash reporting exists; don't uncomment the toggle without a real server. Code:Sources/EdmundCore/Diagnostics/CrashReporter.swift. - Reading works only because Edmund is not sandboxed; adopting App Sandbox would force a MetricKit rewrite (noted in CrashReporter's header).
- Triage of a user's
.ips: it's JSON — a one-line metadata header, then the report body. Look atexception(type/signal),faultingThread, and walk that thread's frames for images namededmdorSparkle. Ad-hoc builds ship no dSYM, so expect addresses rather than symbol names for app frames; correlate with~/.edmund/logsfrom the same timestamp instead..ipsfiles embed the user's home path and device model — treat as mildly personal data.
Update mechanics (user side)
SUFeedURL=https://raw.githubusercontent.com/I7T5/Edmund/main/appcast.xml— the raw-GitHub URL of the checked-in appcast; committing tomainis publishing.SUEnableAutomaticChecksis true; no custom interval is set, so Sparkle uses its default ~24 h cadence (plus a check on launch).- Sparkle downloads the DMG enclosure, verifies EdDSA against
SUPublicEDKey, mounts the DMG, re-validates the Apple code signature (§3.2), installs.
7. Versioning & appcast conventions
| Thing | Convention | Current (2026-07-05) |
|---|---|---|
| Git tag | vX.Y.Z |
v0.1.3 pending its tag; last released 0.1.2 |
CFBundleShortVersionString |
SemVer marketing version | 0.1.3 |
CFBundleVersion |
monotonic integer, +1 per release | 4 |
| CHANGELOG | Keep a Changelog 1.1.0, ## [x.y.z] — YYYY-MM-DD, ### subheads, --- separators |
— |
appcast.xml (checked into repo root): RSS 2.0 with the sparkle: namespace.
One <channel> (title/link/description/language) containing one <item> per
release. Items are inserted before </channel>, so the file reads oldest
→ newest; Sparkle doesn't care about order — it picks by version. Per item:
<item>
<title>Edmund 0.1.2</title>
<pubDate>Fri, 03 Jul 2026 19:03:59 +0000</pubDate>
<description><![CDATA[ …HTML from changelog-to-html.py… ]]></description>
<enclosure url="https://github.com/I7T5/Edmund/releases/download/v0.1.2/Edmund-0.1.2.dmg"
sparkle:version="3" <!-- CFBundleVersion -->
sparkle:shortVersionString="0.1.2" <!-- marketing version -->
sparkle:edSignature="…"
length="7608991"
type="application/x-apple-diskimage"/>
</item>
<description> is optional (omitted when the CHANGELOG section is missing).
8. Roadmap context (for release-content judgment)
- Edmund is in beta (0.1.x line, first public release 0.1.0 on 2026-06-27). Small, frequent releases.
- v0.2.0 goal: "Polished editing experience" (
misc/backlog.md§ Now). - Priority ordering: Marketing = Bugs >= UI/UX > Features — when deciding what makes a release, bug fixes and polish beat new features.
- Long-range plan (v1.0 = onboarding + full GFM + extensions groundwork):
docs/ROADMAP.md; working backlog with per-bug detail:misc/backlog.md.
Provenance and maintenance
Written 2026-07-05 from direct reads of: .github/workflows/release.yml,
scripts/release.sh, scripts/build-app.sh, scripts/changelog-to-html.py,
appcast.xml, CHANGELOG.md, Info.plist, README.md,
docs/ARCHITECTURE.md (§8, §13), misc/how-to-release.md,
misc/before-you-release.md, docs/ROADMAP.md, misc/backlog.md,
Sources/EdmundCore/Diagnostics/CrashReporter.swift,
Sources/EdmundCore/Diagnostics/Log.swift,
Sources/edmd/Settings/AdvancedSettingsView.swift,
Sources/edmd/Settings/AppSettings.swift.
Known stale docs at time of writing: misc/how-to-release.md (zip vs DMG,
§1); Log.swift header ("always-on (opt-out)" vs the actual default-off
toggle, §6). Minor oddity, deliberate: build-app.sh signs with
--identifier "com.i7t5.edmd" while the bundle id is com.i7t5.edmund.
Re-verify when any of these change: release.yml step names or secrets,
build-app.sh signing order, the CHANGELOG header format (the awk regex in
two places must match it), SUFeedURL, RELEASE_TOKEN rotation (hard
deadline 2027-06-27), notarization status, or the crash-report server going
live (which un-inerts §6's crash uploading and this skill's wording).
.claude/skills/edmund-research-frontier/SKILL.md
npx skills add I7T5/Edmund --skill edmund-research-frontier -g -y
SKILL.md
Frontmatter
{
"name": "edmund-research-frontier",
"description": "Open problems where the Edmund Markdown editor could advance the state of the art — product-first, everything labeled candidate\/open, nothing proven. Load when picking the next big problem, evaluating whether an ambitious idea is worth starting, or answering \"what would move this project beyond state of the art\". Each frontier: why current SOTA fails, Edmund's specific asset, the first three concrete steps IN THIS REPO, and a falsifiable \"you have a result when…\" milestone. Not for running an accepted investigation (edmund-caret-integrity-campaign), the method of proof (edmund-research-methodology), or shipping the change (edmund-change-control)."
}
Edmund research frontier
Where Edmund could go past the state of the art. Ambition is product-first: "the CotEditor of Markdown editors" (README). Advanced TextKit 2 techniques are means, not ends — a technique is worth pursuing when it makes the product better, and it becomes publishable as a side effect.
Everything here is candidate / open. Nothing is proven. Each item routes its proof through edmund-research-methodology (hypothesis predicts numbers) and its changes through edmund-change-control. Verified 2026-07-05 against the repo; assets cited are real, outcomes are not.
Frontier 1 — Viewport-stable TextKit 2 at scale (>100k UTF-16)
Why SOTA fails: TK2 lays out only near the viewport; off-screen fragment
heights are estimates corrected as layout reaches them. This makes the
scroller jump and scroll-to-target miss in every TK2 app, including TextEdit
— a widely documented limitation. Above fullLayoutMaxLength (100k) Edmund
enters this regime.
Edmund's asset: the mitigations already in TextView/ —
scheduleFullLayoutSettle, preservingViewportAnchor, repairContentAboveOrigin,
centerViewportOnCaret re-measure, and the diff-based undo restore that avoids
resetting fragments to estimates; plus the ScrollStabilityTests /
HeightStabilityTests harnesses. (Note: repairContentAboveOrigin is
mitigated-unconfirmed live per docs/investigations/viewport-glitch-investigation.md — a
real result here would also retire that honest gap.)
First three steps in this repo:
- Build a large-doc fixture (
makeLargeMarkdowninTestHelpers.swift) >100k and a scripted scroll-accuracy metric (ReproScriptcaret+ alogsel-style position dump, or extendPerfHarnessTests). - Quantify the estimate-error distribution: for N scroll-to-target operations, record predicted vs actual landing pixel offset.
- Prototype persistent per-fragment height caching across the settle (or across sessions) and re-measure the same distribution.
You have a result when: scroll-to-target lands within a stated pixel budget
on a 1 MB document, measured by script, with zero repairing content above origin events across a scroll soak — reproducibly.
Frontier 2 — Caret integrity by construction
Why SOTA fails: every NSTextView consumer depends on didChangeText
pairing that AppKit itself violates (the drag-move bypass). The delete-drift
class is the symptom of building sync on a callback contract AppKit doesn't keep.
Edmund's asset: the heal machinery, the pendingEdit model, six documented
rounds of mechanism knowledge, and the ReproScript + soak methodology. The
campaign skill runs individual rounds reactively; this frontier is the
structural endgame — eliminate the class.
First three steps:
- Inventory every storage-mutation entry point (grep
replaceCharacters,setAttributes, the edit-flow paths) and tabulate which currently rely on a callback firing. - Design a sync layer keyed on a storage-version counter that reconciles
rawSourceregardless of which callbacks fired (not a new guard per path). - Falsify it against all historical
.reproscripts plus a new randomized bypass-fuzzer script.
You have a result when: all historical .repro scripts and a randomized
bypass soak stay green with the callback-pairing assumption deleted from the
code. Candidate, big — likely a multi-PR redesign; do not start without the
methodology skill's evidence bar in front of you.
Frontier 3 — The 10 MB class (performance headroom)
Why it matters: README claims "~1–2 MB files"; native-with-no-Electron is the differentiator, so headroom is a product claim, not vanity.
Edmund's asset: viewport-based lazy styling, the idle drain, incremental
reparse (pendingEdit window), PerfHarnessTests.
First three steps:
- Extend
PerfHarnessTestswith 5/10 MB fixtures (makeLargeMarkdown). - Profile the block-parse and restyle hot paths (
Log.measuresingle-line durations are already in place). - Set explicit latency budgets for open, first-paint, and per-keystroke restyle at 10 MB.
You have a result when: open + steady-state typing latency on a 10 MB file meets a stated budget, measured by the harness (not hand-timed).
Frontier 4 — A native extensions API
Why SOTA fails: Obsidian/VS Code plugin ecosystems are Electron; there is no strong precedent for a native, safe, fast extension surface for live-preview Markdown on macOS. ROADMAP v1.0.0 lists "Extensions API, documentations, primitive marketplace" and flags Advanced Syntax Highlighting / Advanced Math as "official extension" candidates.
Edmund's asset: the custom-parser seam
(Parsing/SyntaxHighlighter+CustomParsers.swift), the BlockKind/styleBlock
architecture, and already-modular opt-in syntax (math, Obsidian syntax).
First three steps:
- Catalog which existing features could be re-implemented as extensions (dogfood: callouts? highlight? wikilinks?) — this defines the real extension points.
- Define the minimal seam: block parser? inline parser? theme hook? Draw the line at what the custom-parser architecture already supports.
- Spike one official extension behind a flag and measure restyle cost vs the built-in.
You have a result when: one built-in syntax feature runs as an extension with no measurable restyle regression against the built-in baseline.
Frontier 5 — Accessibility / RTL / localization as a differentiator
Why it matters: native apps can excel where Electron editors are weak; ROADMAP v1.x lists Localization, RTL, Accessibility. Locale-aware content width already ships as precedent that the pipeline can be locale-sensitive.
Edmund's asset: the attribute-only pipeline (structure is in the string, not in inserted characters), the existing locale-aware content-width path.
First three steps:
- VoiceOver audit of
EditorTextView's custom drawing — does the accessibility tree expose headings/lists/callouts, given they're drawn as decorations? - Test RTL behavior of the attribute-only styling on a right-to-left document.
- Scriptable a11y check (structure read-out) as a regression guard.
You have a result when: a scripted VoiceOver audit reads document structure (headings, list items, callouts) correctly on a mixed document.
How to start one
- Predict numbers first (edmund-research-methodology §2) — every milestone above is a number, not a vibe.
- Instrument to make the current failure/limit cheap to measure.
- Prototype behind a flag; measure predicted vs observed.
- Route changes through edmund-change-control (branch, tests, no auto-push).
- When the first milestone lands, the item graduates to
misc/backlog.mdordocs/ROADMAP.mdand stops being a frontier.
What NOT to start (no current asset)
- iOS / iPadOS port — explicitly TBD in ROADMAP; no shared UI layer today.
- Collaborative / real-time editing — zero repo support (no CRDT, no sync,
single
NSDocumentmodel). Would be a new product, not a frontier of this one. - Anything that requires breaking an invariant (storage == rawSource; TextKit 2 only) to work — that's not a frontier, it's a rewrite (edmund-architecture-contract).
When NOT to use this skill
- Running an accepted investigation (a known bug) → edmund-caret-integrity-campaign / edmund-debugging-playbook.
- The method of turning a hunch into proof → edmund-research-methodology.
- Whether a public claim is allowed yet → edmund-external-positioning.
- Shipping the change → edmund-change-control.
Provenance and maintenance
Verified 2026-07-05. Assets exist; outcomes are unproven by definition — never quote a milestone here as achieved.
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
ls Sources/EdmundCore/Parsing/SyntaxHighlighter+CustomParsers.swift
grep -n 'Extensions API\|RTL\|Localization\|iPadOS' docs/ROADMAP.md
grep -rn 'func makeLargeMarkdown' Tests/EdmundTests/TestHelpers.swift
When an item's milestone lands, move it to ROADMAP/backlog and delete it here — a frontier list that keeps solved problems is lying.
.claude/skills/edmund-research-methodology/SKILL.md
npx skills add I7T5/Edmund --skill edmund-research-methodology -g -y
SKILL.md
Frontmatter
{
"name": "edmund-research-methodology",
"description": "The discipline that turns a hunch into an accepted result in the Edmund Markdown editor — the evidence bar, hypothesis-predicts-numbers, the first-principles analysis recipes, the idea lifecycle, and where good ideas historically came from. Load when forming a hypothesis about a bug's mechanism, evaluating whether an investigation's conclusion is trustworthy, deciding if a fix is actually proven, or turning an idea into an accepted change. This skill also absorbs the proof-and-analysis toolkit (prove it, don't just install it). Not the caret-integrity campaign itself (edmund-caret-integrity-campaign), not the repro tooling (edmund-live-repro-and-diagnostics)."
}
Edmund research methodology
How a hunch becomes something you can ship without it coming back. Every method here is grounded in a real episode from this repo's history — the discipline was paid for in the six delete-drift rounds and the viewport work.
Verified 2026-07-05 against docs/investigations/delete-drift-investigation.md,
docs/investigations/viewport-glitch-investigation.md, and docs/dev-guides/live-repro-guide.md.
1. The evidence bar
A mechanism is accepted only when it clears both bars:
- It explains ALL observations, including the negatives. Not just "the caret drifts" but why headless tests pass, why it's intermittent, why it appears minutes after the trigger. A mechanism that explains the symptom but not the negatives is incomplete — and incomplete mechanisms come back.
- It survives assigned adversarial refutation. Before shipping, actively try
to falsify the supposed fix: run the frozen repro against it. Rounds 1–5
of delete-drift shipped on reasoning and all came back; round 6's first
fix candidate "worked by reasoning" and failed in the repro within a
minute (
docs/investigations/delete-drift-investigation.mdround 6, "the iteration that proved its shape").
Corollary: headless-green is not the bar for live-input bugs — the round-6 regression test passes with and without the fix. See edmund-validation-and-qa for the evidence hierarchy.
2. Hypothesis predicts numbers BEFORE running
State the expected observable before the experiment. An experiment without a predicted number is a fishing trip.
- Worked example: round-6
logselpredicted 321 on the broken build vs 290 on the fixed build — a specific number, stated before the run, that the repro then confirmed every time. - Determinism as a prediction: a soak's final
rawLenmust be byte-identical across runs; predict it, then check it.
If you can't name the number your hypothesis predicts, you don't understand the mechanism well enough to test it yet — go back to instrumentation (§3b).
3. The analysis recipes (prove it, don't just install it)
Each: when to use, the steps, and the episode that earned it.
3a. Trace archaeology — walk BACKWARDS
When: any symptom with diagnostics on. Steps: find the first bad line,
then read upward/earlier, not forward from the symptom. Episode: the round-6
drift at 22:13 was armed by a bypass at 22:11:57 — 80 seconds and dozens of
healthy edits earlier. Source: docs/dev-guides/live-repro-guide.md §2.
3b. Make the failure cheap to observe before reasoning about fixes
When: you're tempted to guess. Steps: add one breadcrumb (a call stack, a
state dump) behind Log.shouldTrace and ship it; reproduce; let the log name the
culprit. Episode: traceSelectionOrigin named _fixSelectionAfterChange in
a single run after five rounds of guessing. This is the meta-lesson: time
spent making the failure cheap to observe beats time spent reasoning about the
fix.
3c. The fidelity ladder as an inference tool
When: a repro fails. Steps: a repro that fails at level N but succeeds at
N+1 localizes the mechanism to what N lacks. Episode: delete-drift not
reproducing in a windowed unit test (level 2) told the team it needed
deferred/queued AppKit state → pointed straight at level-3 in-process replay and
event-ordering hypotheses. Source: docs/dev-guides/live-repro-guide.md §1.
3d. Invariant auditing
When: the behavior looks impossible. Steps: find which invariant
transiently broke — storage == rawSource during IME composition;
didChangeText pairing during a drag-move; grep the guard inventory
(hasMarkedText, bypass checks). Lock it with an equivalence/fuzz oracle:
RecomposeEquivalenceTests (assertMatchesFullRecomposeOracle — incremental
restyle must equal a full restyle) and IncrementalParseFuzzTests (random edits
keep window-reparse == full reparse).
3e. Exact-sequence replication
When: simulating an AppKit-internal path. Steps: replay its real call
sequence, pinned from a stack trace, not an approximation of its effect.
Episode: bypassdelete reproduces shouldChangeText→replaceCharacters
with no didChangeText verbatim — an approximation would have hidden the bug.
3f. Differential measurement over eyeballing
When: any visual or binary claim. Steps: screencapture pixel
measurement for visuals (edmund-live-repro-and-diagnostics §5); shasum
before/after for "did the binary actually change"; predicted-vs-observed tables
for numbers. Episode: the maintainer's rule — when told "balance the padding",
measure the top vs bottom pad in pixels, don't judge by eye.
4. The idea lifecycle
How an idea moves from hunch to settled (or to a documented dead end):
hunch
→ cheap instrumentation / discriminating experiment (§3b, §3c)
→ investigation-doc entry (docs/<topic>-investigation.md, round structure)
→ frozen deterministic repro
→ fix on its own fix/ branch, with test + soak (edmund-validation-and-qa)
→ ARCHITECTURE §8 gotcha entry in the SAME PR (if an invariant changed)
→ settled status in the chronicle (edmund-failure-archaeology)
Retirement path for ideas that fail: a documented dead end in the investigation doc with a "do not retry" note and why (see the round chronicle and the viewport doc's "Verification limits (honest gaps)" section — mitigations that are real but unconfirmed live are labeled so, not oversold).
Where larger ideas are tracked: docs/ROADMAP.md (versioned feature plan)
vs misc/backlog.md (near-term working list; priority order **Marketing = Bugs
= UI/UX > Features**). A frontier idea graduates onto one of these when its first milestone lands (edmund-research-frontier).
5. Where good ideas historically came from
Mine these seams first — they've paid out repeatedly:
- Making failures observable. The diagnostics accumulated one breadcrumb at a time; each named a mechanism the previous round guessed at.
- Reading Apple's actual behavior, not the documented ideal. Sparkle's
update validation is non-strict (
SecStaticCodeCheckValidityWithErrors) — discovered by testing the real API, which unblocked the whole update pipeline.NSWindow.sendEventas the pre-toolbar funnel solved the toolbar right-click aftermenu/rightMouseDown/gestures all failed. - Prior art consulted before inventing.
nodes-app/swift-markdown-engine(ARCHITECTURE §14) solves the same TK2 live-preview problems with different trade-offs — a comparison point and technique source. MarkEdit/CotEditor as product references (ROADMAP). - Field reports with logs + movs.
misc/bug-repros/holds the maintainer's screen recordings and trace logs — the round-4 drag-move mechanism came straight out of one such trace.
6. Anti-patterns (each with its historical cost)
| Anti-pattern | Cost paid |
|---|---|
| Fix at symptom time | Patches the wrong instant — symptom armed minutes earlier (round 6) |
| Trust headless green for a live-input bug | Round-6 test passes with and without the fix |
| Stack speculative fixes | Rounds 1–5 each "fixed" it; each came back |
| Skip document reconstruction | Geometry/block-kind-dependent bugs don't fire on "hello world" |
| Trust a possibly-stale binary | SwiftPM prints Build complete! without relinking → wrong conclusions |
| Declare victory without a soak | One clean repro misses armed-state / degradation bugs |
When NOT to use this skill
- Actually running the caret investigation step by step → edmund-caret-integrity-campaign.
- The repro drivers and trace decoding → edmund-live-repro-and-diagnostics.
- What evidence a change type requires + the test suite → edmund-validation-and-qa.
- The settled history you're checking against → edmund-failure-archaeology.
- Picking the next big problem → edmund-research-frontier.
Provenance and maintenance
Verified 2026-07-05.
grep -nE '^## Round|honest gaps|Verification limits' docs/investigations/delete-drift-investigation.md docs/investigations/viewport-glitch-investigation.md
grep -n '321\|290' docs/investigations/delete-drift-investigation.md # the predicted numbers (§2)
grep -rn 'assertMatchesFullRecomposeOracle' Tests/EdmundTests/
ls misc/bug-repros/ # field-evidence seam (§5)
The methods are stable; the episodes they cite are the drift risk — if a new round rewrites the delete-drift narrative, refresh the worked examples here.
.claude/skills/edmund-validation-and-qa/SKILL.md
npx skills add I7T5/Edmund --skill edmund-validation-and-qa -g -y
SKILL.md
Frontmatter
{
"name": "edmund-validation-and-qa",
"description": "What counts as EVIDENCE in the Edmund Markdown editor, and how to add tests. Load when writing or adjusting tests, deciding what proof a fix needs before it can ship, judging whether a change is actually \"verified\", interpreting the test suite, or adding a golden\/regression check. Covers the evidence hierarchy (headless is necessary but not sufficient for live-input bugs), the test-suite map, the Swift Testing helpers, how to add a test, the golden inventory, visual QA, and performance evidence. Not the gating rules themselves (edmund-change-control) or how to run the live repro (edmund-live-repro-and-diagnostics)."
}
Edmund validation & QA
The discipline of proof. The central lesson: a green headless test is necessary but not sufficient for the bug classes that cost the most time here. Know which evidence each change actually requires.
Framework: Swift Testing (import Testing, @Test, #expect, #require)
— NOT XCTest. Verified 2026-07-05: 810 @Test cases across Tests/EdmundTests/.
1. The evidence hierarchy
Ordered weakest → strongest. The rule at the bottom names which class each change type requires.
| Class | What it proves | Blind spot |
|---|---|---|
(a) Headless unit test (makeEditor()) |
model/parse/style/undo logic | Cannot exercise deferred AppKit machinery — runs it synchronously |
(b) Windowed unit test (real NSWindow + NSScrollView, real deleteBackward) |
+ layout, viewport, first responder | still not real event-loop pacing / IME / drag |
| (c) Frozen live ReproScript repro (exact script + document) | the live input/timing mechanism | needs a debug build + ~1 min/run |
(d) Soak script green across 4–5 cycles, byte-identical final rawLen |
armed-state + determinism | slow |
| (e) Screencapture pixel measurement | anything that DRAWS | manual |
The trap that defines this skill: the delete-drift round-6 regression test passes with AND without the fix — the harness runs the queued selection fixup synchronously, so headless can't see the bug. For caret/IME/drag/viewport-timing bugs, class (a) is not evidence of a fix; you need (c)+(d).
Required evidence by change type (gates enforced in edmund-change-control):
| Change | Requires |
|---|---|
| Pure logic (parse/style/model) | (a) |
| Viewport/lazy-layout | (b), often (e) |
| Anything that draws | (a where testable) + (e) in light AND dark |
| Edit-pipeline / selection / IME / undo | (a) to lock the headless contract + (c) frozen repro + (d) soak |
| Release | see edmund-release-and-operate |
2. Test-suite anatomy
Run: swift test (full suite; ARCHITECTURE cites ~750+ tests ≈10s — 810 @Test
cases as of 2026-07-05). One suite: swift test --filter <Suite>. swift test
also runs automatically as a Stop hook (.claude/settings.json) at the end of
any turn touching code, so failures surface before you commit.
Map of Tests/EdmundTests/ (what the files actually cover):
- Parsing:
BlockParserTests,SyntaxHighlighterTests,IncrementalParseFuzzTests,PendingEditTests,EscapeRenderingTests,HTMLTagRenderingTests,EmojiRenderingTests,LineEndingTests. - Rendering / styling:
BlockStylingTests,CalloutRenderingTests,CalloutTests,CommentRenderingTests,NestedBlockStylingTests,InlineStylingTests,MathRenderingTests,ImageRenderingTests,CodeHighlighterTests,FootnoteTests,WikiLinkTests,TableAlignmentTests,RenderingRegressionTests. - Editor behavior:
EditorIndentationTests,ListContinuationTests,BlockquoteContinuationTests,BlockquoteDeletionTests,FormattingTests,ActiveBulletMarkerTests,NewListItemAlignmentTests. - Edit-pipeline integrity (the costly class):
BypassedEditSyncTests,MarkedTextDesyncTests,WrappedParagraphCaretTests,EditorDiagnosticsTests,UnmatchedDebugTests,InternationalInputTests. - Viewport / layout / undo:
LazyRenderingTests,ScrollStabilityTests,HeightStabilityTests,TypewriterCenteringTests,UndoRedoViewportTests,EditorUndoTests,RecomposeTests,RecomposeEquivalenceTests. - Export / read mode:
HTMLRendererTests,DocumentHTMLTests,ReadModeWebViewTests,HTMLThemeTests,EditorThemeTests,ViewModeTests,ContentWidthTests. - Infra / harness:
LogTests,CrashReporterTests,PerfHarnessTests,StatusBarPrefsTests,FileIntegrationTests,EditorDocumentTests,TestHelpers.swift._RenderDump.swift/_RenderEdit.swiftare local dev tools (gitignored intent) that dump Read-mode HTML / edit output fortmp/sample.md— not part of the assertion suite.
Two invariant-guarding patterns worth copying:
- Recompose equivalence (
RecomposeEquivalenceTests, helperassertMatchesFullRecomposeOracle): an incremental restyle must produce the same result as a full recompose. This is the safety net for the lazy / incremental styling paths. - Incremental-parse fuzz (
IncrementalParseFuzzTests): random edits must keep the parser's window-reparse consistent with a full reparse.
3. Test helpers (TestHelpers.swift)
@MainActor helpers you build on (verified exports):
| Helper | Use |
|---|---|
makeEditor() |
an EditorTextView on the real TK2 chain (mirrors Document.makeWindowControllers) |
ensureFullLayout(...) |
force layout so geometry is real, not estimated |
type(...), paste(...), pressEnter(), pressBackspace() |
drive edits |
activateBlock(...) |
move the caret / active block |
displayText(...), attrs(...), font(...), fgColor(...) |
inspect styled output |
isHidden / isInvisible / isDimmed |
assert delimiter hiding |
blockDecoration(...), textBlockDifference(...) |
inspect decorations / catch TK1-reverting table attrs |
expectedFullComposition(...), drainAllStyling(), assertMatchesFullRecomposeOracle(...) |
equivalence-oracle checks |
makeLargeMarkdown(...), sentence(...) |
build big fixtures for perf/viewport |
styleBlock(_:cursorPosition:) is a method on EditorTextView
(Rendering/EditorTextView+Rendering.swift), called from tests — it renders one
block to an attributed string.
4. How to add a test
Skeleton (Swift Testing, @MainActor because the editor is main-thread):
import Testing
import AppKit
@testable import EdmundCore
@MainActor
@Test func deletingAtCalloutBottomKeepsInvariant() {
let editor = makeEditor()
editor.loadRawSource("> [!note]\n> body\n")
drainAllStyling()
// ... drive the edit ...
#expect(editor.rawSource == editor.string) // storage == rawSource invariant
}
Rules:
- Every bug fix ships with a test even if it can't discriminate a live-only
mechanism — it still locks the headless contract so a future refactor
can't silently re-break the model half. For the live half, keep the frozen
.reproscript alongside (edmund-live-repro-and-diagnostics). - Assert the invariant where you can (
rawSource == string), not just the surface symptom — that's whatBypassedEditSyncTests/MarkedTextDesyncTestsdo. - Name the test for the behavior/bug, put edit-pipeline repros next to their
siblings (the
*Desync*/*Bypassed*/*CaretTestsfamilies). - New drawing behavior additionally needs a screencapture check (§6).
5. Golden / certified inventory
RenderingRegressionTests+RecomposeEquivalenceTestsare the closest thing to golden checks — they pin styled output and incremental-vs-full equivalence.test-files/*.md(callout.md,decorations.md,math.md,menu.md,test.md) are the user's manual test corpus — hand-testing fodder. Do not rewrite them to fit an automated test; build your own fixtures (helpers in §3, or a scratch dir).misc/bug-repros/holds field evidence (.movscreen recordings +.logtraces) for open bugs — reference these when reproducing, don't delete them.
6. Visual QA
Anything that draws is verified by screencapture pixel measurement, in
light AND dark mode (per misc/before-you-release.md), never by eyeballing
headless layout. Method + scripts: edmund-live-repro-and-diagnostics §5.
Headless layout tests (HeightStabilityTests, ScrollStabilityTests) check
geometry numbers but cannot confirm the pixels are right.
7. Flakiness & CI
- A
fix/flaky-math-testbranch exists in history — math rendering has shown timing flakiness; if a math test flakes, check that branch's approach before inventing a new one (verify:git log --oneline --all -- '*Math*'). - CI:
.github/workflows/ci.ymlonmacos-14, latest-stable Xcode, SPM cache keyed onPackage.resolved,concurrency: cancel-in-progress(private-repo macOS minutes bill 10×). CI runs the sameswift test.
8. Performance evidence
PerfHarnessTestsmeasures the hot paths;makeLargeMarkdownbuilds big fixtures. Use it (don't hand-time) for any perf claim.- The README claim "handles ~1–2MB files" and
fullLayoutMaxLength = 100_000UTF-16 (EditorTextView.swift:80) mark the boundary between the full-layout regime (≤100k, geometry is real) and the estimate regime (>100k, viewport glitches possible). A perf/viewport claim must state which regime it was measured in.
When NOT to use this skill
- The gate/branch/commit rules → edmund-change-control.
- Running the live repro or measuring pixels → edmund-live-repro-and-diagnostics.
- The caret-integrity investigation end to end → edmund-caret-integrity-campaign.
- Why a mechanism works → textkit2-appkit-reference / edmund-architecture-contract.
- Release verification → edmund-release-and-operate.
Provenance and maintenance
Verified 2026-07-05.
grep -rh '@Test' Tests/EdmundTests/*.swift | wc -l # ~810 cases
grep -c 'import Testing' Tests/EdmundTests/TestHelpers.swift # confirms Swift Testing, not XCTest
grep -oE 'func [a-zA-Z]+' Tests/EdmundTests/TestHelpers.swift # helper inventory (§3)
ls Tests/EdmundTests/ # suite map (§2)
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
If a helper in §3 no longer greps it was renamed; update §3 and any test
skeletons. Re-derive the suite map from ls if files are added/removed.
.claude/skills/textkit2-appkit-reference/SKILL.md
npx skills add I7T5/Edmund --skill textkit2-appkit-reference -g -y
SKILL.md
Frontmatter
{
"name": "textkit2-appkit-reference",
"description": "Domain-theory pack for the AppKit text system as it applies to the Edmund Markdown editor. Load when working on layout, selection, IME, undo, drag, or eventing behavior; when TextKit 2 or NSTextView does something surprising; or when terms like NSTextLayoutManager, layout fragment, marked text, queued selection fixup, responder chain, or sendEvent appear and you lack AppKit text-system background. Explains the mechanisms the invariants and gotchas are built on. Not the invariants themselves (edmund-architecture-contract), not a triage table (edmund-debugging-playbook), not the repro drivers (edmund-live-repro-and-diagnostics)."
}
TextKit 2 / AppKit reference (as it applies to Edmund)
The background a mid-level engineer or Sonnet-class model usually lacks. Each concept: brief theory, then where it bites in Edmund with a verified file pointer. This is not a textbook — it is only the parts that matter here.
Facts checked against source 2026-07-05. Items labeled (background) are general AppKit/TextKit behavior grounded in Apple's documentation, not directly grep-able in this repo.
1. The TextKit 2 object model (background + repo)
TextKit 2 replaced the TextKit 1 NSLayoutManager stack. The players:
NSTextContentStorage— owns the backing string + attributes (the model).NSTextLayoutManager— lays text out (the TK2 analogue of the old layout manager).NSTextLayoutFragment— one laid-out chunk (≈ a paragraph); has a real geometric frame only once laid out.NSTextElement/NSTextParagraph— the model-side elements fragments render.
Viewport-based layout is the headline difference: TK2 lays out only the content near the visible viewport, not the whole document. That is what makes big documents fast — and it is the root of most viewport pain (§2).
Where it bites: Edmund subclasses the fragment as DecoratedTextLayoutFragment
(EditorTextView+TextKit2.swift) to draw callout boxes, bars, and overlays.
2. Height ESTIMATES — the master cause of viewport glitches
A fragment that has not been laid out yet has an estimated height, not a real one; the total document height is the sum of real + estimated fragment heights. As layout reaches a fragment, its estimate is replaced by the true value and everything below shifts. Consequences: the scroller thumb jumps, and "scroll to offset Y" lands wrong because Y was computed from estimates. This is a widely documented TK2 limitation — even TextEdit shows it (background).
Where it bites / Edmund mitigations (verify names by grep; all in TextView/):
fullLayoutMaxLength = 100_000(EditorTextView.swift:80): documents ≤ 100k UTF-16 units are kept fully laid out (no estimate regime) by a coalesced next-run-loop settle.scheduleFullLayoutSettle/preservingViewportAnchor: the settle runs inside an anchor block so corrections never shift what is on screen.repairContentAboveOrigin(+LazyStyling.swift, logsrepairing content above origin): fixes the case where an edit near the top strands the first fragment at negative y (unreachable above the scroller top).centerViewportOnCaret: re-measures after its first scroll and corrects the residual estimate error.
Rule: never trust an off-screen fragment's y-coordinate without laying out
its span first. Deep write-up: docs/investigations/viewport-glitch-investigation.md.
3. The TextKit 1 fallback trap
An NSTextView can silently and permanently revert from TK2 to the legacy
TK1 stack. Two known triggers: accessing NSTextView.layoutManager (the
mere getter engages TK1), and storing NSTextBlock/NSTextTable
attributes. Once reverted, TK2 APIs still exist but do nothing useful, and the
whole editor misbehaves subtly.
Where it bites: Edmund ships a DEBUG tripwire — an observer on
NSTextView.willSwitchToNSLayoutManagerNotification that asserts if the switch
happens (EditorTextView.swift:273+, message "TextKit 1 fallback triggered").
Never add code that reads layoutManager or stores table attributes; draw
tables as decorations instead (EditorTextView+TableRendering.swift).
4. Attribute-only mutation semantics
Edmund renders by writing attributes onto the storage, never by inserting or deleting characters (the storage == rawSource invariant). Two consequences from the text system:
setAttributesdoes not re-measure geometry. After a restyle that changes a block's height or indent, you must callinvalidateLayout(for:)on its range or the fragment keeps a stale frame (empty bands / clipped lines).recomposeDirtyand the idle drain already do this; new paths must too.NSTextAttachmentis only honored on theU+FFFCobject-replacement character (background).rawSourcenever containsU+FFFC, so attachments can't be used — Edmund draws images/icons as overlays (§5) instead.
5. The custom drawing model (fragments, decorations, overlays)
DecoratedTextLayoutFragment draws two attribute families behind/over text:
.blockDecoration(paragraph-level): callout boxes, quote bars, table borders, thematic-break rules, code backgrounds. Fragments tile vertically, so a multi-line run renders as one continuous box. A box'sbottomPadgrows the last fragment's frame (TK2 omits trailing paragraph spacing from the fragment, so padding done otherwise would be dead space)..fragmentOverlay(character-level): an image or a stroked vector path drawn at a glyph's laid-out position — rendered math, list bullets/checkboxes, callout header icon, the custom-title callout icon (path). The anchor glyph is hidden (≈0.01 pt font + clear color) and.kernreserves the drawing's advance width.
The image-on-wrapping-fragment wedge: drawing an image overlay on a
multi-line (wrapping) fragment re-triggers a layout pass that collapses the
fragment to one line. Drawing a shape/path does not. So the wrapping
callout title's icon is a stroked CGPath (parsed by SVGPath from vendored
Lucide geometry), never an image. Full saga:
docs/investigations/archives/callout-title-wrap-investigation.md. This constraint holds for any new
overlay that could share a line with wrapping text.
Hiding text = hiddenFont (≈0.01 pt) + clear foregroundColor. This is how
delimiters (**, `, [!note]) vanish without changing the string.
6. The queued selection fixup (the round-6 delete-drift mechanism)
When you mutate an NSTextView's storage, AppKit queues a private step,
-[NSTextLayoutManager _fixSelectionAfterChangeInCharacterRange:], that repairs
the selection against the new character coordinates. Normally it fires promptly.
But if an edit bypasses the normal close-out (see §7), the fixup stays
queued and fires at the next endEditing — even an attribute-only
restyle — where it maps the now-stale selection against post-edit
coordinates and leaps the caret blocks away. It will move even a freshly set,
valid caret.
Where it bites: this is delete-drift round 6. The heal must set the caret
(from the pendingEdit hull) before the sync and re-assert it after
(EditorTextView+EditFlow.swift). Recognize a variant by: a suspicious
selection change arriving mid-recompose (up=Y in traces);
traceSelectionOrigin will log the call stack of whoever moved it.
Critical for testing: a headless test harness runs this deferred fixup synchronously, so this bug class cannot reproduce in a unit test — the round-6 regression test passes with and without the fix. Only the live in-process repro discriminates (edmund-live-repro-and-diagnostics).
7. The AppKit edit pipeline contract (and where AppKit breaks it)
Normal edit: shouldChangeText(in:replacementString:) → the view calls
replaceCharacters → didChangeText(). Edmund's didChangeText syncs
rawSource from storage and restyles the edited block(s).
AppKit does NOT always send didChangeText. A drag-move of selected text
whose drop lands on no valid target (e.g. released past the end of the
document) deletes the dragged range via shouldChangeText → replaceCharacters
and never calls didChangeText — silently freezing rawSource/blocks,
after which every edit drifts and autosave writes stale content (delete-drift
round 4). Edmund heals this: shouldChangeText schedules a next-run-loop
bypass check (scheduleBypassedEditSyncCheck, +EditFlow.swift); an
unconsumed storage pendingEdit by then means the close-out never came, and the
editor runs the sync itself (breadcrumb: healing storage edit that bypassed didChangeText).
Never build a sync path on the assumption that didChangeText follows every
edit.
The authentic key route (background + repo): keyDown →
interpretKeyEvents → insertText: / deleteBackward:. This is why the repro
driver synthesizes real NSEvents and pushes them through window.sendEvent(_:)
rather than calling insertText directly — shortcuts skip deleteBackward's
selection machinery, which is exactly where round 6 lived.
8. IME / marked text lifecycle
While an input method is composing (e.g. CJK, accents), the view holds
provisional "marked" text in storage; hasMarkedText() is true. During this
window storage == rawSource is transiently false, and didChangeText
defers syncing until the composition commits.
The cascade: any styling path that runs
beginEditing/setAttributes/invalidateLayout mid-composition can strand
the marked text in the input context. After that, didChangeText keeps bailing
on its own guard and the invariant stays broken — so every later edit drifts
the caret (the original delete-drift bug). Therefore every storage-touching
styling path must guard !hasMarkedText() — including async ones scheduled
before composition began (the caret-move restyle in +SelectionTracking).
becomeFirstResponder resyncs from storage as a catch-all. Full write-up:
docs/investigations/delete-drift-investigation.md.
9. Responder chain & nil-target actions (background + repo)
The responder chain is AppKit's search order for who handles an action.
Menu items and toolbar buttons with a nil target send their action up the
chain until something responds. Edmund's Format menu (FormatMenu.swift) is a
declarative command table whose items use nil targets and route to the focused
EditorTextView's @objc format… actions — the same wiring as undo/redo. The
first responder is normally the focused EditorTextView.
10. NSWindow.sendEvent — the pre-toolbar event funnel
Every event a window receives passes through sendEvent(_:) before the
toolbar acts. This matters because with NSToolbar.allowsUserCustomization = true, the toolbar claims any secondary (right/control) click over the
toolbar — including a custom item view — for its own "Customize Toolbar…" menu,
downstream of view-level handlers (menu, rightMouseDown, gesture
recognizers all lose). Edmund's fix for the view-mode button: intercept in
DocumentWindow.sendEvent(_:), pop the menu when the click is inside the
button's bounds, and swallow it (return); other clicks fall through to
super. (Caveat: true fullscreen moves the toolbar to a separate window, so
this main-window hook wouldn't cover it.)
11. Drag sessions (background + repo)
- Text drag-move arming: AppKit only starts a text drag after a mouse-down hold (~400 ms); a CGEvent driver must hold before moving or the drag never arms.
- Drag-select autoscroll: dragging past the viewport edge autoscrolls.
- Reveal at nearest end: a selection taller than the viewport must be
revealed at its nearest end (Edmund's
scrollRangeToVisibleoverride) — always revealing the top fought the drag-select autoscroll and oscillated the viewport mid-drag.
12. swift-markdown walker model (brief)
Edmund parses with apple/swift-markdown (CommonMark/GFM) and walks the
resulting Document with two back-ends: a SpanCollector-style walk that
produces editor attributes, and HTMLRenderer that produces HTML for Read mode.
One parser, two outputs — so Edit and Read can't drift. Custom (non-CommonMark)
syntax — callouts, ==highlight==, wikilinks, comments, footnotes, math — is
handled by SyntaxHighlighter+CustomParsers.swift.
When NOT to use this skill
- The project's rules/invariants (what you must not do) → edmund-architecture-contract.
- Which symptom means which mechanism → edmund-debugging-playbook.
- Reproducing a live bug / reading traces → edmund-live-repro-and-diagnostics.
- The history of how these mechanisms were discovered → edmund-failure-archaeology.
- Running the caret-integrity campaign → edmund-caret-integrity-campaign.
Provenance and maintenance
Verified 2026-07-05. Re-verify the load-bearing identifiers:
grep -n 'fullLayoutMaxLength' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'willSwitchToNSLayoutManager' Sources/EdmundCore/TextView/EditorTextView.swift
grep -n 'scheduleBypassedEditSyncCheck\|healing storage edit' Sources/EdmundCore/TextView/EditorTextView+EditFlow.swift
grep -rn 'repairContentAboveOrigin\|preservingViewportAnchor' Sources/EdmundCore/TextView/
grep -rn 'blockDecoration\|fragmentOverlay\|hiddenFont' Sources/EdmundCore/
grep -rn 'hasMarkedText' Sources/EdmundCore/TextView/
Items marked (background) are AppKit/TextKit behavioral facts documented by
Apple and in docs/*-investigation.md, not directly observable by grep. If any
Edmund mitigation name above no longer greps, it was renamed — update this file
and docs/ARCHITECTURE.md §5/§8 together.


