Quoin is a native WYSIWYG markdown editor for macOS (iOS/iPadOS later). Swift/SwiftUI + TextKit 2, zero JavaScript at runtime, local-only.
docs/design/handoff.md+docs/design/Markdown Editor Design Doc.dc.html— the visual/interaction spec. High fidelity: colors, type ramp, spacing, and states are final. Canonical option choices: 1a Graphite, 1e classic tree sidebar, 1h ruled outline, 1k rounded text styling.docs/archive/TRD.html— architecture (native engines, session model). Where it conflicts with the handoff, the handoff wins; note the conflict.docs/archive/PRD.html— original viewer-scoped PRD, superseded by the handoff for scope but still valid for performance budgets and privacy stance.docs/reference/architecture.md— contributor-level machinery map (data flow, editing model, math/diagram engines, invariants).README.mdcarries the public support matrix; keep both in sync with real capabilities.
- Source of truth is the markdown string + AST (swift-markdown/cmark-gfm),
NEVER attributed strings. The editor is a projection; edits mutate the
source through
DocumentSessionand the renderer re-projects. - Documents are plain
.mdfiles on disk. Folders = directories. - View models are platform-free; only navigation containers differ.
- Never override system shortcuts: ⌘P print, ⌘E use-selection-for-find, ⌘H hide.
- Round-trip (open → edit → save) must be byte-lossless for untouched regions.
One code dependency: swift-markdown. Anything new requires written justification in the TRD first; the default answer is no.
Sources/QuoinCore— platform-agnostic engine (parses, sessions, search, stats, exporters, math/mermaid parsers + diagram layout geometry). Must build and test on Linux.Sources/QuoinRender— attributed-string projection + TextKit 2 typesetting and diagram drawing. Shared engine files (guardedcanImport(AppKit) || canImport(UIKit)) sit at the target root; the platform view layers are isolated in subfolders —AppKit/(the macOSNSTextVieweditor:QuoinTextView,ReaderCoordinator,MarkdownReaderView) andUIKit/(MarkdownReaderViewIOS, the iOS/iPadOS/visionOS reader). Both paths compile in CI. NOT Mac Catalyst-safe: on CatalystcanImport(AppKit)is true, so the AppKit guards would need&& !targetEnvironment(macCatalyst)to route Catalyst to the UIKit branch.App/macOS,App/iOS— app shells; projects generated with XcodeGen (project.ymlin each).Tests/QuoinCoreTests— every core feature gets tests here, including performance budgets (PerformanceTests) and pathological inputs (TortureTests).- Screenshot automation:
-QuoinLibraryPath,-QuoinShotOpen,-QuoinShotState,-QuoinForceDarkModelaunch arguments preset app state; CI publishes PNGs to theci-screenshotsbranch.
AttributedRenderer(QuoinRender) projects aQuoinDocumentinto one attributed string. Every block's range is tagged withQuoinAttribute.blockID; block-level chrome is tagged withQuoinAttribute.blockDecoration(aBlockDecorationvalue).QuoinTextView(its own file; the reader view is split acrossMarkdownReaderView.swift/ReaderCoordinator.swift/QuoinTextView.swift) is the NSTextView subclass that draws those decorations behind the text indrawBackground(in:), using TextKit 2 fragment frames so shapes track reflow. Code canvases, callout boxes, quote rules, diagram frames, table rules, and the front-matter chip are all drawn here — NOT with.backgroundColorattributes (per-glyph backgrounds render as ugly per-line strips; that was a shipped bug once).- Syntax reveal: the active block re-renders as its literal source via
MarkdownSourceStyler, character-for-character 1:1 with the file (hidden delimiters are 1pt clear text, never removed — edit mapping depends on this). Span delimiters reveal only when the caret is inside the span; structural line prefixes (>,- [ ]) stay faded-visible. When adding a new inline span type, add BOTH a renderer case inAttributedRendererand a styler pass inMarkdownSourceStyler, and register its delimiter in theclaimed-ranges ordering (**before*, links before emphasis). - Interactive runs use link plumbing:
quoin-task://(checkboxes),quoin-anchor://(heading jumps),quoin-copy://(code-block copy button readsQuoinAttribute.copySource). Handle new schemes inCoordinator.textView(_:clickedOnLink:at:). - Scroll-to-block commands (outline clicks) use
scrollTarget+scrollGeneration— the generation bump is what re-fires a repeat click on the same heading; don't compare targets alone. - Panel toggles (⌘0 sidebar, ⌥⌘0 outline) are View-menu commands in
QuoinApp.commandsdelivered by NotificationCenter (AppDelegate.toggleSidebarNotification/toggleOutlineNotification). System window tabbing is disabled (allowsAutomaticWindowTabbing = false) because Quoin has its own document tabs.
- Package work:
swift buildandswift testat the repo root; that's what CI runs. The app:cd App/macOS && xcodegen && xcodebuild -project Quoin.xcodeproj -scheme Quoin -configuration Debug build. Both targets share DerivedData atQuoin-auvclcixelydkdfodldptdztmdln. - Run the dev build directly for log capture:
…/Build/Products/Debug/Quoin.app/Contents/MacOS/Quoin > /tmp/quoin.log 2>&1 &— NSLog lands in that file. Kill withpkill -x Quoin; only run ONE instance (a second one fights over the library). - Hang forensics without Xcode:
sample Quoin 3(non-invasive) orlldb -p <pid> --batch -o 'bt all' -o 'detach'(full symbols). Unified log: use/usr/bin/log— plainlogis a zsh builtin that errors with "too many arguments". - The app is sandboxed.
-QuoinLibraryPathworks for folders inside the container / test fixtures, but NOT for arbitrary user folders from a plain CLI launch — a normal launch restores the saved security-scoped bookmark instead. Clint's live library is~/Documents/ClintNotes(real notes — be gentle;Quoin UX Test.mdat its root is a kitchen-sink fixture exercising every element type). - SourceKit in-editor diagnostics ("No such module 'QuoinCore'", "Cannot
find type … in scope" for newly added files) are stale-index noise; the
compiler is the arbiter. Trust
swift build/xcodebuild.
- Bundle id is
ai.2389.Quoin; request access by bundle id (the display name may not resolve). Finder also only resolves ascom.apple.finder. - Synthetic Escape keypresses NEVER reach the app (verified with an NSEvent local monitor: letters arrive, keyCode 53 doesn't). Do not report "Escape doesn't work" bugs from synthetic input — check with a real keyboard or instrument with a local event monitor first.
- The first click into an inactive window can be swallowed by window activation; a "click did nothing" needs a second attempt before it's a finding.
- Layout-sensitive verification (colors, rules, spacing) should be zoomed, not judged from the downsampled full screenshot — inline math once looked wrong at full-screen scale and was pixel-perfect zoomed.
- A scrollable
NSTextViewneedsisVerticallyResizable = trueANDmaxSizelifted togreatestFiniteMagnitudeAND an unlimited-height text container. DefaultmaxSizeis the initial frame — with a zero frame the view silently can't grow, so nothing scrolls andscrollRangeToVisiblesnaps back. - Every newline inside a rendered block is a paragraph: paragraph styles
built from
paragraphStyle()inherit the 12pt body gap. Code blocks, tables, and any multi-line block must zeroparagraphSpacingexplicitly or lines render double-spaced. - Decoration geometry can be queried mid-reflow: after any attribute pass
that changes fonts (syntax reveal flips 1pt↔full-size delimiters), call
invalidateDecorations()— it schedules a second draw after TextKit settles, otherwise boxes lag behind their text. MarkdownSourceStyler.styleLinePrefixesmust advance byNSMaxRange(lineRange); the old clamped-location loop pinwheeled forever on the last line.- Flowchart layering must ignore cycle back-edges or layout loops
(
DiagramLayout).
- Commit and push after every meaningful unit of work, to
main(user directive). - CI runs
swift teston a macOS runner (.github/workflows/ci.yml). Cloud/Linux sessions have no Swift toolchain — CI is the compile feedback loop; keep it green. - Design assets from the user land in
docs/design/. - When a UX/design question comes up, check
docs/design/handoff.mdfirst — it is specific down to point sizes and alpha values, and most "what should this look like" questions are already answered there.