โ† Back to Plan

Resource Segmented View Plan

Resource Page

A Resource opens into ResourceDetailView: one long scrolling page, no

picker. Which sections it shows, and in what order, depends on the kind:

KindPage, top to bottom
Bookbook header (the editor's own: cover, author, title) โ†’ notes โ†’ divider โ†’ **quotes** (TOC if captured, else quote list, else the capture-TOC empty state)
Articleheader (chip, title, author, "Open original") โ†’ notes โ†’ divider โ†’ **the clipped article text**, as a serif reading surface
Bookmarkheader โ†’ 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).


1. Design decisions

D1. Where "notes on a resource" live

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.
  • **Resolution:** the Note whose companion is the resource id; failing that
  • (unmigrated vaults) the oldest non-archived Note whose resources contains

    it (VaultManager.findCompanionNote).

  • **Creation is lazy:** with no companion, the Notes tab opens in new-note
  • 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.

    D2. Container and routing

    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.

    D3. Which segments โ€” decided in KMP

    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>

    ```

  • BOOK โ†’ [NOTES, QUOTES]. The quotes section always exists โ€” its empty
  • state hosts the capture-TOC affordance.

  • ARTICLE โ†’ [NOTES, RESOURCE_TEXT] when the body is non-blank, else
  • [NOTES].

  • BOOKMARK โ†’ [NOTES] (the body is only "Resource: <url>", already in the
  • header).

    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.

    D4. The page sections

  • **Quotes** โ€” ResourceQuotesSection composes the existing
  • ResourceTocComponent (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.

  • **Notes** โ€” NoteEditorContent, unchanged for books and bookmarks (editing
  • the Resource). For articles it edits the companion note, or opens in new-note

    mode with initialResources: [srcId].

  • **Article text** โ€” ArticleBlocksSection, a read-only render of the body
  • block-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.

    D6. How the sections are hosted (2026-08-20)

    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:

  • **No nested scroll views.** The editor was already one long scroll
  • internally, so caret-visible-while-typing keeps working unchanged.

  • **The body gets a required 200pt minimum height** when a bottom accessory
  • exists. Without it, an empty note collapses to a single line that is

    near-untappable with content pressing up from below.

  • **The tap-below-text handler ignores the bottom accessory.**
  • 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.

  • Accessory views are read once, in makeUIViewController, so each one is a
  • thin wrapper that reads the @Observable ResourceDetailModel in its own

    body. A value snapshot would never pick up a TOC capture or a sync pull.

    D7. Why the article parses off the main thread (2026-08-20)

    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's
  • inline 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 a
  • Task.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:), so
  • building body crosses the bridge zero times.

  • Inline images load through a shared AsyncLimiter(width: 3) in
  • VaultThumbnailResolver, 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.

    D5. Suppressing the in-editor TOC

    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.)


    2. What was built

    Shared (KMP)

    FileChange
    model/ResourceViewPlanner.ktnew โ€” ResourceSection + ResourceViewPlanner (was ResourceSegment + defaultSegment/showsPicker before the one-page rework)
    db/SteelNotes.sqnew query getNotesReferencingResource (LIKE over the CSV column, narrowing only)
    vault/VaultManager.ktnew findCompanionNote(resourceId) โ€” exact membership re-check, skips archived, oldest first
    jvmMain/.../PlatformFileSystem.jvm.ktwriteText 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.

    iOS

    FileChange
    Features/Resource/ResourceDetailView.swiftnew โ€” one-page container + resource chrome (was the segmented container)
    Features/Resource/ResourceDetailModel.swiftnew โ€” ResourceDetailDataSource, NotesTarget, ResourceDetailModel
    Features/Resource/ResourceQuotesSection.swiftnew โ€” divider + TOC / list / empty modes (was ResourceQuotesView)
    Features/Resource/ArticleBlocksSection.swiftnew โ€” read-only article text (was ArticleResourceView)
    Features/Resource/ResourceContentHeader.swiftnew โ€” the page header for an article or bookmark (was ArticleResourceView.header)
    Features/Resource/ArticleBodyRenderer.swiftnew โ€” markdown โ†’ renderable blocks
    Features/NoteEditor/NoteEditorItemFactory.swiftnew โ€” extracted from createNewItem, adds initialResources
    NoteEditorView.swiftroutes an existing Resource to ResourceDetailView
    NoteEditorContent.swiftinitialResources, showsInlineResourceSections; createNewItem delegates to the factory
    Editor/UnifiedEditorView(Controller).swiftshowsResourceSections flag; topAccessory / bottomAccessory hosted sections
    Services/SharedCoreService.swiftfindCompanionNote passthrough

    Tests (all written before the code, all passing)

    SuiteCovers
    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.

    3. Verified in the simulator

    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).

    3b. Validation round (2026-08-20)

    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.

  • **The notes are a surface, the article is not.** Making the article text
  • "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.

  • **Placeholder.** bodyPlaceholder ("Add your notes here...") draws a greyed
  • label 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.

  • **Smaller when empty.** The panel's floor drops from 200pt to 150pt for a
  • 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.

  • **System section headers.** SystemSectionHeader โ€” bold, uppercased, steel
  • blue โ€” 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.

    3c. Validation round 2 (2026-08-20)

    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.

  • **No panel.** notesSurfaceView (and UIColor._steelNotesSurface with it)
  • is gone. The notes are the page background โ€” white in light mode.

  • **"MY NOTES".** bodySectionLabel on UnifiedEditorView(Controller) inserts
  • a 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.

  • **"ARTICLE".** ArticleBlocksSection opens with
  • SystemSectionHeader("Article", systemImage: "doc.text") in place of the

    hairline rule it used to draw.

  • **The article's ground.** The article reads on _steelArticleGround, which the
  • page 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 what
  • separates 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.

    3d. Validation round 3 (2026-08-20)

    Two corrections to round 2's labelling, both about how the page reads rather

    than how it is built.

  • **Grey labels, not steel blue.** 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.

  • **A ground you can actually see.** Round 2's _steelGrouped (0xF2F1EE) sat
  • six 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.

    The first-character caret bug

    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.

    4. Known gaps

  • ~~Deleting a Resource leaves its companion Note's resources entry dangling.~~
  • Closed by COMPANION_NOTES.md ยงC2: the delete dialog

    offers keep-as-note (which drops the reference) or delete-with.

  • ~~A companion note created from the Notes tab gets its title from the
  • 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 the
  • file.

  • No Android UI; the KMP pieces are the shared groundwork for one.