← Back to MarkeMark

Changelog

The development story behind MarkeMark — every version, every decision.

v1.6 Build 10 Apr 23, 2026

Build 10 delivers the UX win Build 7 promised — inline images just render everywhere, no banner, no folder prompt — without Build 7's Quick Look regression. Sandbox comes off the main app only; the Quick Look extension stays sandboxed (which macOS requires for extension registration). Open any .md file and its sibling images render immediately. Spacebar-preview any file in a folder you've already touched and its images render there too.

Engineering note: com.apple.security.app-sandbox removed from markemark.entitlements (main app) only; MarkeMarkQuickLook.entitlements keeps its sandbox, which is what pluginkit needs for QL extension registration (Build 7's regression). pbxproj flipped ENABLE_APP_SANDBOX to NO and dropped ENABLE_USER_SELECTED_FILES for the main-app build configs; QL extension build settings unchanged. Main app Info.plist gains NSDesktopFolderUsageDescription, NSDocumentsFolderUsageDescription, NSDownloadsFolderUsageDescription, NSRemovableVolumesUsageDescription, NSNetworkVolumesUsageDescription — these TCC prompts are no-ops for sandboxed apps (sandbox denies before TCC gets a chance, per Apple Developer forum guidance) but do fire for non-sandboxed apps, which is the regime we're in now. ContentView.DocumentURLReader.onURL kicks off ImageCache.populateFromFolder on every new document URL, caching the whole folder's images into the app-group container so the sandboxed Quick Look extension hits on first preview of any sibling doc. The banner UI (folderAccessBanner, missingImageCount, grantFolderAccess) is removed from ContentView; FolderAccessManager is now unused in the main app and retained as dead code for one build so rollback stays a one-commit revert — will be deleted in Build 11. release-app.sh unchanged — step 5b re-signs each target with its own entitlements file, which correctly drops sandbox on main and keeps it on the appex.
v1.6 Build 9 Apr 23, 2026

Quick Look was silently broken after Build 7. Spacebar-previewing a .md file in Finder fell through to the plain-text system generator — raw markdown source, no rendering, no images. Build 9 puts the sandbox back on both the app and the Quick Look extension, which is what QL needs to register at all. The "Grant Folder Access…" banner returns alongside it; the tradeoff Build 7 tried to make turned out to cost the feature that matters most.

Engineering note: Build 7's com.apple.security.app-sandbox drop silently un-registered the .appex with pluginkit — Apple's QuickLook preview extension host refuses unsandboxed extensions. The LaunchServices record for the installed app carried no plugin Identifiers: line, and pluginkit -m -p com.apple.quicklook.preview omitted MarkeMark entirely, so Finder fell through to /System/Library/QuickLook/Text.qlgenerator. Survey of every third-party QL preview extension in /Applications on this machine: all had com.apple.security.app-sandbox = 1; MarkeMark post-Build-7 was the only outlier. Fix reverts both entitlements files to their pre-Build-7 contents: QL extension gets app-sandbox + application-groups + files.user-selected.read-only; main app gets app-sandbox + print + files.user-selected.read-write + application-groups. The release-app.sh step 5b re-sign remains necessary and keeps doing the right thing — with sandbox re-enabled, it's a no-op drift guard instead of a drift remover. The ImageCache app-group bridge built in v1.6 Build 1 still carries Quick Look: once the main app reads an image (via bookmark grant or per-doc user-selected.read-write), the bytes are cached into the app-group container where the sandboxed QL extension can read them directly without its own file-access entitlement ever needing to resolve.
v1.6 Build 8 Apr 23, 2026

Scroll-lock moved off the toolbar (it was pushing the view-mode trio off centre) and back onto the pane divider — this time near the bottom, where it's out of the way while still being a natural home for a pane-sync control.

Engineering note: Restored SplitPaneView from Build 5's commit (200399c) — two panes with a dividerOverlay ViewBuilder slot and @SceneStorage("splitFraction") per-window persistence. Lock button is bottom-aligned inside that slot via VStack { Spacer(); Button(…).padding(.bottom, 20) } so it hugs the bottom of the visible divider regardless of window height. Toolbar-side scroll-lock button deleted; view-mode trio is the only content left in the toolbar group, restoring its natural centring.
v1.6 Build 7 Apr 23, 2026

MarkeMark now reads sibling images — both in the main editor preview and in Finder's Quick Look — with no folder-access prompt. Open any .md file that references images in the same folder and they just render. No more "Grant Folder Access…" banner, no more Quick Look previews with broken images for files you haven't opened yet.

Engineering note: com.apple.security.app-sandbox removed from both markemark.entitlements and MarkeMarkQuickLook.entitlements, along with the now-meaningless files.user-selected.read-write / read-only and print entitlements. com.apple.security.application-groups retained — the app-group container still backs ImageCache as a perf path, and FileManager.containerURL(forSecurityApplicationGroupIdentifier:) requires the entitlement even on unsandboxed processes. The existing FolderAccessManager bookmark machinery and the "Grant Folder Access" banner are now dead code paths (they'd only fire if Data(contentsOf:) somehow still failed, which for real-world paths it won't) — left in place as a no-op safety net and for easy restoration if the sandbox ever comes back. ImageInlinerQL's "try direct disk read first, fall back to app-group cache" order now succeeds on the direct read, skipping the cache lookup; the cache path stays in case the QL extension is ever re-sandboxed. Notarization and Developer ID signing are unaffected — hardened runtime still applies (the -o runtime codesign flag), Developer ID distribution accepts sandboxed and unsandboxed bundles alike.
v1.6 Build 6 Apr 23, 2026

Two fixes on top of Build 5. The lock moved to the toolbar (bigger, easier to hit) and a rendering glitch where bits of ![[yoga.jpg]] syntax leaked into the preview as visible text is squashed.

Engineering note: The stray-text bug was processWikiLinks matching [[foo.jpg]] inside ImageInliner's emitted <img … data-original-src="![[foo.jpg]]">. The wiki-link regex was scoped to skip <code>/<pre> contexts but not arbitrary HTML tag attribute contexts, so the [[…]] in the attribute got hijacked into an anchor, breaking the attribute string and leaking fragments to the DOM. Fix: inside-open-tag detection via last < vs last > position in the pre-match text — if the last < has no matching > before the match, we're inside an attribute list and skip. Same guard should probably land on other block/inline passes eventually, but this is the only one Adam has hit in practice and the others don't match [[…]]. Toolbar button is a standalone Button with Image(systemName:) at 15 pt vs the 12 pt view-mode glyphs, in a 30 × 24 frame (vs 26 × 22) so the size difference reads as deliberate.
v1.6 Build 5 Apr 23, 2026

UX polish on the scroll-lock toggle introduced in Build 4. The link icon in the toolbar was using an SF symbol that doesn't exist on macOS 13, so the button vanished when clicked. Moved to a lock icon on the divider between the panes — a more natural spot for a pane-sync control.

Engineering note: SwiftUI.HSplitView doesn't expose its divider for custom content, so the .split case now renders through a small new SplitPaneView — two panes, a 1 px visual hairline over an 8 px draggable hit zone, and a dividerOverlay ViewBuilder slot for the lock button. Split fraction is persisted via @SceneStorage("splitFraction") so each window remembers its own geometry across relaunches. The drag gesture captures the fraction at drag start so translation is applied relative to the grab point rather than the current clamped position, which otherwise drifts during a continuous drag against the min-width clamps. Icon choice avoids any SF Symbol introduced after macOS 13 — lock / lock.open are OS-baseline and can't render blank on older targets.
v1.6 Build 4 Apr 23, 2026

Based on hands-on testing of Build 3: the pane-to-pane selection overlay was still "weird" even after follower-only logic and edit-driven clears — so it's gone. And a new scroll-lock toggle lets you decouple the two panes when a tall image pushes text out of view.

Engineering note: Full rip of SelectionOverlayView (an NSView subview added to the NSTextView), PreviewActions.showSelectionOverlay/hideSelectionOverlay, the SELECTION: bridge message, the #selection-overlay <div> and its CSS/JS (_applySelectionOverlay, _selectionOverlayRaf, etc.), and the wire-up through ContentView.handleEditorSelection/handlePreviewSelection. The sync caret (SyncCaretView + #sync-caret) stays — it's precise, source-line-based, and fast. Scroll-lock is a plain @AppStorage("scrollSyncEnabled") bool gating both handleEditorScrollProportion and handlePreviewScrollProportion; caret mirroring is left on because it's discrete-event-driven, not a per-frame driver of the viewport, so it doesn't fight the "let each pane scroll on its own" intent. ImageCache.store(…) now dispatches to a utility-QoS serial queue and compares the to-be-written source mtime against the existing .meta plist's mtime key — if they match within 1 s, the write is skipped entirely. The Quick Look extension is the only consumer of the app-group cache and it runs only when the user QL-previews a file, so there's no in-session reader that needs the write to have landed synchronously.
v1.6 Build 3 Apr 23, 2026

Follow-up polish based on hands-on feedback from Build 2 — scroll now feels smooth through big images, and the selection overlay behaves sensibly instead of stacking.

Engineering note: The scroll-sync refactor replaces source-line-based messages (SCROLL_LINE carrying a fractional line number) with SCROLL_PROP carrying a 0–1 proportion of each pane's scrollY / maxScroll. Motion through asymmetric content — one editor line mapping to a 600 px image — is now linear rather than piecewise-interpolated, so the classic "24 px editor scroll → 300 px preview scroll while inside a tall block" jump is gone. EditorActions.scrollToProportion and PreviewActions.scrollToProportion are the Swift-side entry points; computeScrollProportion / scrollPreviewToProportion are the JS-side. The velocity-clamp catch-up loop from Build 2 stays, now acting on proportion-mapped Y values. Follower-only overlay logic gates on previewHasFocus in ContentViewhandleEditorSelection no-ops when preview is the leader and vice versa, and handleFocusChange wipes both overlays on every focus flip so the old follower's stale mirror doesn't persist. .onChange(of: document.text) calls clearSelectionOverlays() and the innerHTML-swap JS pipes hideSelectionOverlay() after the DOM replacement to prevent pixel-pinned overlay positions from lingering over re-laid-out blocks.
v1.6 Build 2 Apr 23, 2026

Split-pane polish — images now show up on first open, scrolling feels smooth through tall blocks, and your selection is mirrored across panes so you can see what's about to be affected.

Engineering note: Three localized fixes. The image-first-open race was a loadHTMLString vs evaluateJavaScript ordering bug — the initial render fires from .onAppear before DocumentURLReader's KVO on window.representedURL populates documentURL, so ImageInliner returns the markdown unchanged (no base64), then the URL-arrival re-render tries to patch the DOM via innerHTML before the in-flight loadHTMLString has finished parsing. Fix: a pageReady flag on Coordinator, flipped by a new WKNavigationDelegate.webView(_:didFinish:) handler, gates the inner-HTML path so any HTML update arriving while the page is still loading triggers a fresh loadHTMLString instead. Scroll smoothing is a velocity clamp in the preview's _applyPreviewScroll: cap each frame's step at max(40, |delta| × 0.35) px with a catch-up requestAnimationFrame loop that closes the gap; small deltas (normal prose scrolling) are unaffected, large deltas converge in ~10 frames. Selection mirroring is an overlay-only implementation — the editor/preview each report their selection range as (startLine, endLine) source-line pairs over the existing bridge, and the follower pane draws a semi-transparent NSView / <div> spanning the block range. No attempt to hold a real DOM selection in both panes at once (which would fight native focus).
v1.6 Build 1 Apr 23, 2026

Obsidian-style vaults now render inline images — in the main editor's preview and in Finder's Quick Look. Drop a ![[screenshot.png]] or ![alt](refs/diagram.png) and the image shows up.

Engineering note: Cross-target image sharing routes through a SHA256-keyed cache in the app-group container (<group-container>/image-cache/), not through security-scoped bookmarks. URL(resolvingBookmarkData:, options: .withSecurityScope, …) consistently fails in sibling sandboxed extensions despite matching team ID and application-groups entitlements — App Groups are a reliable data sharing mechanism, bookmark blobs are not. The main app writes through on each successful disk read in ImageInliner and runs an eager prefetch pass when the user grants folder access. Round-trip safety comes from a data-original-src="references/foo.png" attribute stamped onto every generated <img> tag, preferred over the base64 src during HTML→Markdown conversion so the original path (or Obsidian embed syntax) is preserved verbatim on save.
v1.5 Build 6 Apr 22, 2026

Pane sync gets polished — smoother scrolling, crisper caret blink, and a more precise indicator that tracks where you're actually editing. Plus wiki links now render correctly in Finder's Quick Look previews.

Engineering note: PreviewActions now queues pending scrollToSourceLine / showSyncCaret targets and flushes them together via DispatchQueue.main.async — at most one evaluateJavaScript call per run-loop pass, regardless of how fast NSClipView fires boundsDidChange. The blink uses step-end keyframes with visibility: hidden (not opacity: 0) so the GPU can't interpolate; the editor-side CAKeyframeAnimation was simplified from four-value-with-duplicate-keytimes to a clean two-value discrete blink. Caret precision within a block is a fractional line report — editor sends lineNumber + charOffset/lineLength, preview interpolates using the block's own getBoundingClientRect().height instead of the inter-block gap. The Quick Look extension's MarkdownRendererQL pre-processes [[wiki]] syntax into inline <a class="internal-link"> anchors before handing off to swift-markdown, which passes raw inline HTML through unchanged.
v1.5 Build 5 Apr 22, 2026

The two panes now scroll together. Drag either side and the other follows to the same spot in your document — no more hunting for your place after a long scroll.

Engineering note: Each top-level block in the rendered HTML gets a data-source-line="N" attribute pointing to its 0-indexed line in the markdown source. The preview's JS reports the source line of the first visible anchor on scroll; the editor's NSScrollView clip-view bounds observer does the same via the layout manager's glyph-to-line lookup. Each side uses a 250 ms ignore window after receiving a programmatic scroll so the resulting echo doesn't re-trigger a round-trip. The full-text-replace path in EditorView.updateNSView also now saves and restores the clip view origin, fixing the older "jump to top when preview edit arrives" glitch. data-source-line is stripped in stripBrowserFormattingTags so it never leaks into the markdown on the way back.
v1.5 Build 4 Apr 22, 2026

Three round-trip corruption bugs squashed. Editing in the preview pane no longer silently rewrites your markdown.

Engineering note: Root cause was a one-line ordering bug — stripBrowserFormattingTags was removing class and id attributes globally before convertCodeBlocks and convertFootnotes ran, so those converters never saw the metadata they needed (class="language-swift", id="fn-1", class="footnotes"). Fix: move the two metadata-dependent converters ahead of the stripper. Separately, the code-block regex was widened to tolerate highlight.js's hljs class and to strip its token <span>s from the captured content. The bare-URL fix just checks whether the display text equals the href before emitting markdown.
v1.5 Build 3 Apr 22, 2026

Obsidian wiki links finally look like links — no more raw [[page|alias]] noise cluttering your printouts.

Engineering note: Target and alias are stored in separate data-wiki / data-wiki-alias attributes so edit detection can compare the current inner text against what the original display would have been, then promote any edit to an alias. Inline-markdown chars (*, _, ~, =, `, [, ]) inside wiki syntax are swapped for numeric HTML entities before the bold/italic/highlight/code/link passes see them, so an asterisk in a page name like [[*🧗 aExplore]] can't corrupt the surrounding HTML.
v1.5 Build 1 Apr 22, 2026

Printing, for real this time. Checklists, schedules, and reference sheets that actually fit on one page.

Engineering note: Six different print-code paths (WKWebView, PDFDocument, PDFView-in-window, custom NSView, in-memory round-trips, disk round-trips) all failed with the same generic "This application does not support printing" error. The culprit turned out to be a missing com.apple.security.print sandbox entitlement — the sandbox was silently blocking every print IPC call regardless of API surface. The print implementation itself was fine the whole time.
v1.4 Build 7 Feb 27, 2026

Formatting shortcuts finally work where you'd expect them to — in the preview pane.

Engineering note: The root cause was the AppKit menu system consuming key events before the WKWebView's JavaScript handler could intercept them. The fix adds a PreviewActions bridge that calls document.execCommand() directly in the WebView, with focus tracking to route format actions to the correct pane.
v1.3.1 Build 6 Feb 27, 2026

The app can now tell you when a new version is available.

v1.3 Builds 4–5 Feb 24–27, 2026

The first public release. Getting a macOS app from "works on my machine" to "works on anyone's machine" turned out to be its own project.

Engineering note: The Quick Look extension required debugging three separate root causes — an API mismatch with QLIsDataBasedPreview, an SE-0409 import visibility change, and WKWebView's XPC sandbox incompatibility. Notarizing the DMG itself (not just the app inside) was also necessary to avoid Safari's quarantine dialog.
v1.2 Build 3 Feb 12–13, 2026

The release where MarkeMark went from a working prototype to something you'd actually want to use daily.

The Great Simplification Feb 13, 2026

After 4+ sessions and ~1,125 lines of code spent on scroll sync and selection mirroring between panes, the root design flaw became clear: two independent algorithms that had to agree on block counts, but couldn't — the markdown renderer and the DOM structure used fundamentally different counting. Every fix introduced a new edge case.

The solution was to delete all of it. Sync cursors, selection highlighting, scroll tracking, source-line annotation — all removed. Sometimes the best code is code you delete.

What was removed: SyncCursorView, FocusTrackingTextView, ActivePane enum, data-source-line annotation system, and ~15 JavaScript functions for focus tracking, cursor synchronization, and scroll position mirroring.
v1.1 Build 2 Feb 6, 2026

Focused on making the editor feel responsive rather than adding new features.

v1.0 Build 1 Feb 6, 2026

The starting point. One question: what if you could edit either side of a markdown editor?

Engineering note: The entire app — SwiftUI interface, markdown rendering, bidirectional editing, and this website — was built with Claude Code.