A Resource opens into ResourceDetailView: one long scrolling page, no
picker. Which sections it shows, and in what order, depends on the kind:
| Kind | Page, top to bottom |
|---|---|
| Book | book header (the editor's own: cover, author, title) โ notes โ divider โ **quotes** (TOC if captured, else quote list, else the capture-TOC empty state) |
| Article | header (chip, title, author, "Open original") โ notes โ divider โ **the clipped article text**, as a serif reading surface |
| Bookmark | header โ notes |
The segmented picker this document originally described was removed on
2026-08-20 (Asana 1216987519580329): the sections are now hosted inside the
editor's own scroll view, so the page scrolls as one and the notes editor
grows with its content rather than scrolling internally. Section 1 below
records the decisions that still hold; the picker-specific ones are marked.
Background: NOTE_LIFECYCLE.md (load/view/save pipeline),
EDITING_FLOW.md (editor internals).
Status: implemented. Verified in the simulator against a real vault
(book with a captured TOC, clipped article, companion-note round trip).
Book: the Resource's own bodyText, as before. A book has no external body
content in the vault โ its text isn't there, quotes are separate notes โ so
the body is already the user's notes surface, and capture already appends
voice notes to it (CaptureProcessor.appendVoiceNotesToResource). No
migration.
Article: the article text occupies Resource.bodyText, so user
notes need their own document. (A bookmark was in this bucket when this
plan was written; as of 2026-08-22 it is in the book bucket above โ its body
is the user's notes, since it has no text of its own.) They live in a companion Note โ a regular
Note in notes/ with resources: [<src-id>] frontmatter (DB column
resource_refs), the pattern NOTE_ARCHITECTURE already describes and
VaultManager.createResourceWithNote already builds.
The editable thing being a first-class Note is the point: it is searchable,
linkable, transcludable, and shows up in the notes list. The Resource stays the
immutable external artifact. No new type.
**Superseded in part by COMPANION_NOTES.md.** The
companion is still a first-class Note, but it now carries an explicit
companion: key rather than being inferred, it is titled after its resource,
and an unfiled one is browsed through the Resource instead of appearing in
the notes list. "No new frontmatter key" did not survive: leaving identity
inferred is what made resolution a heuristic.
Note whose companion is the resource id; failing that(unmigrated vaults) the oldest non-archived Note whose resources contains
it (VaultManager.findCompanionNote).
mode; the existing create-on-first-save path materializes it, carrying
resources: [srcId] from birth. Opening the tab and typing nothing writes
nothing.
Rejected: appending article notes to the Resource body under a delimiter
heading โ fragile segmentation of one string, breaks "Resource = external
content", and sync merge would mix article text with user edits.
Not done (deliberately): migrating book notes onto companion Notes so both
kinds are uniform. That is a data migration for a cosmetic gain.
NoteEditorView.body dispatches Quote โ QuoteDetailView, existing
Resource โ ResourceDetailView, everything else โ the shared editor shell. A
new resource still uses the shell: it has nothing to show in any segment yet.
ResourceDetailView owns the segmented picker and the chrome that used to sit
in editorShell for resources โ nav title, ellipsis menu (frontmatter, cover
viewer, TOC capture, delete), TOC-capture cover, cover backfill.
Segments are hidden, not removed. All segment views stay in a ZStack
with opacity + allowsHitTesting; tearing UnifiedEditorView down on each
flip would rebuild its UITextView and fire its onDisappear save mid-screen.
ResourceViewPlanner (shared/.../model/ResourceViewPlanner.kt) is pure and
unit-tested, so the rules are shared with any future Android UI:
```kotlin
enum class ResourceSection { NOTES, QUOTES, RESOURCE_TEXT }
fun plan(kind: ResourceKind, hasBody: Boolean): List<ResourceSection>
```
[NOTES, QUOTES]. The quotes section always exists โ its emptystate hosts the capture-TOC affordance.
[NOTES, RESOURCE_TEXT] when the body is non-blank, else[NOTES].
[NOTES] (the body is only "Resource: <url>", already in theheader).
Every plan opens with NOTES: what the user writes comes before what they
are writing about. ResourceDetailModel holds the loaded data and exposes
showsQuotesSection / showsArticleSection; there is no selection state
left to keep, so a reload only ever adds or drops a section.
ResourceQuotesSection composes the existingResourceTocComponent (TOC list, or flat quote list when no TOC), draws
the divider that separates it from the notes above, and owns the empty
state with the Capture Table of Contents button. Chapter taps push the
existing ChapterQuotesRoute; quote taps navigate by id.
NoteEditorContent, unchanged for books and bookmarks (editingthe Resource). For articles it edits the companion note, or opens in new-note
mode with initialResources: [srcId].
ArticleBlocksSection, a read-only render of the bodyblock-by-block via ArticleBodyRenderer + InlineMarkdownParser, parsed
off the main thread (see D7) and rendered from cached inline runs, in
Georgia with generous line spacing so it reads as resource material rather
than something to type in. ResourceContentHeader (chip, title, author, link) sits at the top of the page
instead, once โ and for a bookmark it also draws the cover as a full-width
banner (ResourceCoverBanner: the picture's height fits the banner, and the
space left over to either side is filled with the colour sampled off that
edge of the picture, so the artwork reaches the screen edges instead of
sitting in a letterbox) and makes the title editable, since that page has
no other chrome. Not a disabled
UnifiedEditorView โ the offset map, undo history and reconciliation exist
to keep an editable buffer honest and buy nothing here.
UnifiedEditorView(Controller) takes two optional AnyView accessories โ
topAccessory (first in the content stack) and bottomAccessory (last,
after the body text view) โ hosted through UIHostingController with
sizingOptions = .intrinsicContentSize, the same way the in-editor TOC
section already was. Both default to nil, so the full-screen note editor is
untouched.
Consequences worth knowing:
internally, so caret-visible-while-typing keeps working unchanged.
exists. Without it, an empty note collapses to a single line that is
near-untappable with content pressing up from below.
scrollViewTapped focuses the body when a tap lands below its last line;
a tap on the article text or quote list reads exactly like that, and
without the guard it yanks the keyboard open.
makeUIViewController, so each one is athin wrapper that reads the @Observable ResourceDetailModel in its own
body. A value snapshot would never pick up a TOC capture or a sync pull.
The accessory is sized by .intrinsicContentSize, so every block of a clipped
article lays out at page open โ accepted, and deliberately not changed. What is
not acceptable is doing the parsing there too: a 15k-word clip is thousands of
blocks, each crossing the KMP bridge for the block grammar and again for the
inline tokenizer, and the tokenize pass ran inside body, so it repeated on
every re-evaluation.
ArticleBodyRenderer.Block now carries runs: [InlineRun] โ the block'sinline markdown, already tokenized into a Sendable value. SwiftUI.Text is
not Sendable, which is why the intermediate exists. The run type and the
tokenize pass live in InlineMarkdownParser next to the renderer that turns
them back into Text, so the shared component owns the whole
string โ runs โ Text path and ArticleBodyRenderer just carries them.
ResourceDetailModel.refresh() runs ArticleBodyRenderer.blocks(of:) in aTask.detached and publishes back on the main actor, ignoring a result whose
body is no longer the newest one. Previous blocks stay on screen while a
re-parse runs, so a sync pull can't blank the article mid-read.
ArticleBlocksSection renders through InlineMarkdownParser.text(from:), sobuilding body crosses the bridge zero times.
AsyncLimiter(width: 3) inVaultThumbnailResolver, so opening a clip with a hundred images queues the
decodes/fetches instead of starting them all at once. Every image still
loads; scroll-gated laziness was explicitly rejected as too many moving parts.
Known limit: article images whose attachment never localized fall to the
http(s) branch of ArticleBlocksSection, which renders AsyncImage and so
bypasses the limiter โ those still start together.
The Notes tab must not render the TOC/quote list a second time. The flag is
explicit โ showsResourceSections on UnifiedEditorView(Controller) โ rather
than feeding the editor an empty resourceTableOfContents. An empty TOC means
"this book has none", which made the header offer Add Table of Contents
on a book that already had one. (Caught in the simulator; now regression
tested in ResourceHeaderDisplayTests.)
| File | Change |
|---|---|
model/ResourceViewPlanner.kt | new โ ResourceSection + ResourceViewPlanner (was ResourceSegment + defaultSegment/showsPicker before the one-page rework) |
db/SteelNotes.sq | new query getNotesReferencingResource (LIKE over the CSV column, narrowing only) |
vault/VaultManager.kt | new findCompanionNote(resourceId) โ exact membership re-check, skips archived, oldest first |
jvmMain/.../PlatformFileSystem.jvm.kt | writeText now creates the parent directory, matching the Apple implementation and its own writeBytes/moveFile |
That last one is a real cross-platform fix, not test scaffolding:
initializeVault prunes the empty resources/ folder as a legacy directory,
so a JVM/Android vault could not write its first resource. iOS was unaffected
because its writeText already created parents.
| File | Change |
|---|---|
Features/Resource/ResourceDetailView.swift | new โ one-page container + resource chrome (was the segmented container) |
Features/Resource/ResourceDetailModel.swift | new โ ResourceDetailDataSource, NotesTarget, ResourceDetailModel |
Features/Resource/ResourceQuotesSection.swift | new โ divider + TOC / list / empty modes (was ResourceQuotesView) |
Features/Resource/ArticleBlocksSection.swift | new โ read-only article text (was ArticleResourceView) |
Features/Resource/ResourceContentHeader.swift | new โ the page header for an article or bookmark (was ArticleResourceView.header) |
Features/Resource/ArticleBodyRenderer.swift | new โ markdown โ renderable blocks |
Features/NoteEditor/NoteEditorItemFactory.swift | new โ extracted from createNewItem, adds initialResources |
NoteEditorView.swift | routes an existing Resource to ResourceDetailView |
NoteEditorContent.swift | initialResources, showsInlineResourceSections; createNewItem delegates to the factory |
Editor/UnifiedEditorView(Controller).swift | showsResourceSections flag; topAccessory / bottomAccessory hosted sections |
Services/SharedCoreService.swift | findCompanionNote passthrough |
| Suite | Covers |
|---|---|
ResourceViewPlannerTest (KMP, 7) | section sets per kind, notes always first and never repeated |
CompanionNoteTest (KMP jvm, 9) | resolution, oldest-wins, multi-resource, archived skip, quote-not-a-note, **id-prefix trap**, file round-trip |
NoteEditorItemFactoryTests (6) | initialResources on notes only, unchanged defaults, field mapping |
ResourceDetailModelTests (22) | which sections show, notes target per kind, late quotes, TOC pickup, a clipped body disappearing, background article parse (staleness, no re-parse, no blanking) |
EditorAccessoryTests (12) | accessory ordering around the body, nil accessories leave the stack untouched, body minimum height and self-sizing floor, notes surface, body placeholder |
EditorValueHistoryTests (4) | stale-render detection: last push, older generations, genuinely external text, bounded window (since replaced by EditorBodyRevisionTests) |
ArticleBodyRendererTests (21) | block marker stripping, inline markers preserved, code fences, blanks, unique ids, inline runs (styles, links, off-main-actor parse) |
AsyncLimiterTests (4) | concurrency cap, queue drain, cancellation, value pass-through |
ResourceHeaderDisplayTests (+3) | section suppression, and that suppression โ "no TOC" |
Full suites green: 490 Swift tests, all KMP jvmTest.
Against the real vault: a book with a captured TOC opens on Quotes showing
chapters with quote-count badges; its Notes tab shows the resource header and
editor with no duplicate TOC and no spurious capture button. An article opens
on Notes; the Article tab renders the clipped text with inline links and bold
intact. Typing in an article's Notes tab created
notes/note-2026-07-22-cce1abf7.md with resources: [share-2026-07-20-154839],
and reopening resolved that same note rather than creating a second one
(one file references the resource, not two).
Review of the first cut asked for the notes section to be legible as its own
thing, and turned up a caret bug that predated the branch.
"more bookish" a second time would have fought the reading styles that were
already approved, so the distinction is drawn on the notes instead: a tinted,
hairline-outlined panel (UIColor._steelNotesSurface) drawn behind the
body in the scroll view. Deliberately not an arranged subview of the content
stack โ re-parenting the body would break index-based insertion into the
stack and change the wrapped text width. Its frame tracks the body in
viewDidLayoutSubviews, inset horizontally only, so the text width is
untouched.
bodyPlaceholder ("Add your notes here...") draws a greyedlabel over an empty body. Nil in the full-screen editor. It is handed to
SelfSizingTextView.placeholder, which owns a TextViewPlaceholder that
hides and shows itself off the text storage (NSTextStorageDelegate) โ
typing, pasting, undo, a rebuild and an attachment edit all sync for free,
with no visibility call at any mutation site. NoteTitleTextView uses the
same helper for the title.
page section. Two things had to agree: the required constraint in
embedBottomAccessory and SelfSizingTextView.minimumHeight, which is the
value that actually decides the height โ changing the constraint alone left
the panel at 200.
SystemSectionHeader โ bold, uppercased, steelblue โ marks a section the app owns rather than a heading the user typed.
The resource page's quotes/TOC use it and are no longer collapsible
(ResourceTocComponent(collapsible:)); the in-editor TOC keeps its
collapsible grey header.
The tinted notes panel from round 1 read as a box rather than a page. The
second round of feedback replaced it with labelling: the notes sit on the
plain page background, and each section says what it is.
notesSurfaceView (and UIColor._steelNotesSurface with it)is gone. The notes are the page background โ white in light mode.
bodySectionLabel on UnifiedEditorView(Controller) insertsa SystemSectionHeader-styled label into the content stack directly above
the body, wherever the fields above it happen to end. Nil in the full-screen
editor, which is the whole screen and needs no heading.
ArticleBlocksSection opens withSystemSectionHeader("Article", systemImage: "doc.text") in place of the
hairline rule it used to draw.
_steelArticleGround, which thepage fades into over 40pt above the label. Drawn as the section's own
.background, bled past the accessory wrapper's insets with negative
padding (16 horizontal, 32 bottom) so the tint reaches the screen edges and
the bottom of the page instead of floating in a box. The article text
styling is untouched โ the distinction is the ground, not the type.
ResourceContentHeader drops its bottom hairline: "MY NOTES" is whatseparates the header from the notes now.
Undo/redo needed no change โ the capsule was already always-visible
(updateToolbarVisibility) and already scoped to the editor's own
editorStorage.undoHistory, which on a resource page is the note and nothing
else. Confirmed in the simulator rather than assumed.
Two corrections to round 2's labelling, both about how the page reads rather
than how it is built.
SystemSectionHeader.labelColor is_steelSecondary. Steel blue read as a link or as a heading in its own
right and pulled the eye off the note. The constant is the one voice for
every heading the app writes: UnifiedEditorViewController reads it for the
UIKit "MY NOTES" label so the SwiftUI and UIKit headings cannot drift.
_steelGrouped (0xF2F1EE) satsix values of red from the page background (0xF8F7F4), so the 40pt fade into
it was invisible on screen. _steelArticleGround (light 0xE9E7E1, dark
0x121214) is a real step down. EditorAccessoryTests pins the gap: the
ground must be darker than the page by at least 0.025 of perceived
brightness, in both appearances โ a tint that close to the background again
fails the suite rather than shipping as an invisible gradient.
Typing the first character after focusing the editor left it a position short,
so it read as having jumped to the end of the note; on an empty note the
character was dropped outright. Not introduced by this branch โ it
reproduces in the full-screen editor, which the branch does not touch.
SwiftUI captures the view struct's bodyText when it renders, and an update
queued before the keystroke can arrive after it, carrying the pre-keystroke
text. updateUIViewController compared that value against a single
lastSyncedBodyText, so text one generation behind read as an external edit
and clobbered the editor with its own stale content โ rebuilding the storage
twice and landing the caret one position back.
The fix was EditorValueHistory: the last 8 values the editor pushed to its
binding, so a late render is recognized as stale rather than external.
makeUIViewController seeds the baseline with the opening text, without which
the empty-note case ("" never recorded) still wiped the first character.
Superseded: the ring was replaced by a bodyRevision generation stamp
(Coordinator.reconcileRenderedBody), which decides staleness by comparison
instead of by recognizing text. See EDITING_FLOW.md.
resources entry dangling.~~Closed by COMPANION_NOTES.md ยงC2: the delete dialog
offers keep-as-note (which drops the reference) or delete-with.
existing on-device title generation.~~ Closed by ยงC3: it is titled
"Notes โ <resource title>" at birth, which also suppresses generation.
Resource.pages still has no DB column, so it round-trips only through thefile.