Restores the collapsed YAML-frontmatter Metadata card that the WebKit rendering cutover (T-1542) dropped, rendered as a native <details> on the web document path.
BlockHTMLEmitter now emits .metadata as a closed <details class="prism-metadata"> with the catalog-resolved title and the escaped YAML in a <pre><code>, instead of an empty <section hidden>.toggle listener in prism-theme.js already reports any <details>, so the card's open state lives in DetailsExpansionCoordinator and survives a WebContent recovery replay.MarkdownBlock.isRenderedInDocumentBody and DocumentLayoutCoordinator.nearestRenderedIndex — both existing only to skip the hidden carrier — are removed, and three live tests that pinned the carrier now pin the rendered card.MetadataView is deleted; its Metadata catalog key is reused and its four other keys removed.data-prism-kind marker dropped, the body class given a real CSS consumer, a stale agent note rewritten, two fragile negative assertions tightened.Ready to push
The change is small, total over the block model, and reuses the page's existing <details> toggle plumbing rather than adding script. Three review agents raised seven findings; all were fixed in the working tree except one pre-existing pattern the efficiency agent explicitly scoped out of this branch. After the fixes the 64 tests across the emitter, parity and three live-WebKit suites pass through Tools/check-test-results.sh, both platform builds are warning-free, and SwiftLint, stylelint and the WebKit test-isolation guard are clean. The one caveat is documented in the report: this project has no JUnit-emitting runner, so the page carries no structured test data.
71f3cd51 Restore the collapsed YAML frontmatter card on the web document path bab3f613 Add bugfix report for the missing frontmatter card working-tree Fixes applied in this review Some markdown files start with a small block of settings between two --- lines, called YAML frontmatter. It holds things like the document's title, author, or tags. Prism used to show that block as a small collapsed card labelled Metadata at the top of the page, which you could tap to expand. When the app switched to a new rendering engine, that card quietly disappeared: the file was still read correctly, but the card was written out as an invisible, empty box. This change brings the card back.
A reader opening a document could no longer see its metadata at all without switching to the raw text view. Restoring the card returns a feature the app already had, and it does so with the browser's own built-in expand/collapse control, so there is no new custom code to go wrong.
The parser already produced a .metadata(content:) block; the defect was in BlockHTMLEmitter.emit, whose .metadata branch wrote <section data-prism-metadata hidden></section>. It now calls a new emitMetadata that wraps a closed <details class="prism-metadata"> in the block's section: a <summary data-prism-chrome> carrying RenderSettings.Strings.metadataTitle, and the YAML escaped inside <pre class="prism-metadata-body"><code>. catalogStrings() resolves the title from the existing "Metadata" key. document.css styles the card on the code-block surface with the body capped at 300px, the height the old SwiftUI card scrolled within.
The card deliberately rides existing infrastructure. prism-theme.js listens for toggle on any <details> inside a block section and posts detailsToggled; DetailsExpansionCoordinator.applyDetailsToggle records the DOM id with no .details lookup; the synchronizer pushes setDetailsState back. The card is never seeded open because seeding reads only .details blocks' isOpenByDefault. Notes and search are unaffected: supportsNotes is false and searchableText is empty for .metadata, and prism-notes.js keeps skipping data-prism-metadata.
Once the section lays out, the rendered-ness filters that skipped the carrier become vacuous, so isRenderedInDocumentBody and nearestRenderedIndex are removed rather than left always-true. The JS predicate isSectionRendered is unchanged; it simply stops excluding the block because its rect is non-zero.
A custom toggle with its own script would have duplicated the disclosure, focus and accessibility handling <details> provides. Syntax-colouring the YAML via highlight.js was rejected as louder than the old card; the <code> carries no language- class so the page-world highlighter never touches it. Excluding the card from the reading-position scan was rejected because it is genuinely visible at the document top, so landing on it is correct.
emitMetadata never calls renderInline, so the block records no data-prism-run spans and no source-map entries; the YAML lands in the cached per-parseRevision HTML exactly once, escaped in one off-main pass. A closed <details> does not lay out its body, so a multi-thousand-line frontmatter costs one text node until opened, then a single 300px scroll box. topmostBlockSection gains one getBoundingClientRect per tick, the same as any added section.
The toggle path is byte-identical to an authored disclosure: toggle → detailsToggled → change-detecting applyDetailsToggle → one setDetailsState echo → notifyDetailsStateApplied, which only re-asserts a navigation target inside an open window. No reflow-anchor capture is triggered (only applyTypography raises notifyReflowImminent). A reparse re-seeds openDetailsDOMIDs from .details blocks only, closing the card, exactly as a closed-by-default author <details> behaves.
The removal of isRenderedInDocumentBody moves an invariant from code into documentation: applyScrollPositionID picking visibleBlocks[clampedIndex] is correct only because visibleBlocks is collapse-filtered (the attribute half of isSectionRendered) and every emitted block kind now has a non-zero rect (the rect half). The rewritten section of docs/agent-notes/scroll-persistence.md states that if any block kind ever becomes display:none in the page again, the native filter must return or the two sides disagree and T-1944's JS redirect masks it. That is the load-bearing consequence of this change.
WebHiddenSectionGuardTests case pins.[data-prism-metadata] text on both sides, so it could not detect the card's absence and still cannot; a structural-marker check in WebParityFixtureTests now pins presence instead.FootnotePopoverView re-resolves catalogStrings() on every body evaluation and this adds a seventh lookup it can never display; pre-existing, out of scope, noted for a follow-up.activatingAnotherSessionPersistsOutgoingPosition, one of four locale configurations) traces to ScrollPositionStore.save's unlocked load-modify-save on shared UserDefaults under concurrent host processes. Untouched here; worth a ticket.prism/Services/WebRendering/BlockHTMLEmitter.swift
Why it matters. This is the whole user-visible fix. The card is a native disclosure with the catalog title in the summary and escaped YAML in the body, so it is inert to markdown, HTML and script by construction.
What to look at. BlockHTMLEmitter.swift: emitMetadata (new), .metadata case
prism/Views/DocumentLayoutCoordinator.swift
Why it matters. Removes nearestRenderedIndex and the isRenderedInDocumentBody model property. The picker's correctness now rests on an invariant rather than a filter, which the agent note records.
What to look at. DocumentLayoutCoordinator.swift: applyScrollPositionID
prismTests/WebRendering/WebCollapsedSectionScrollTests.swift
Why it matters. Three T-1851/T-1944/T-1701 tests asserted the carrier was never reported or landed on. Each now asserts the opposite, so the suite pins the new behaviour instead of silently passing.
prism/Resources/WebRenderer/document.css
Why it matters. All three agents flagged data-prism-kind="metadata" as unread. It is gone; .prism-metadata-body now carries the body rule so the class has a consumer; cursor: pointer was dropped for consistency with authored details; the two negative hidden-attribute checks now inspect the whole opening <section> tag.
What to look at. document.css: .prism-metadata-body; BlockHTMLEmitterTests.metadataCard; WebScrollPositionRetentionTests.rawToRenderedAtTopLandsOnFrontmatterCard
docs/agent-notes/scroll-persistence.md
Why it matters. The note documented the removed filter by name and told readers to consult a deleted doc comment. It now states the invariant the picker depends on and what has to come back if a block kind ever stops laying out.
What to look at. docs/agent-notes/scroll-persistence.md: Native Position Pickers Must Agree With the Page About What Lays Out
Reuses the page's generic toggle listener and the native open-state record; a custom control would duplicate focus, accessibility and recovery handling that already exists.
Matches the pre-cutover card. DetailsExpansionCoordinator seeds only from .details blocks' isOpenByDefault, so no coordinator change is needed.
Every block kind lays out now; a predicate that cannot be false is dead code and its doc comment would describe a carrier that no longer exists. The invariant moved into the agent note.
The old card showed plain text; colouring would make the card louder than the document. The <code> carries no language- class so highlight.js and the media driver skip it.
Existing translations carry over. The other four keys had no caller after MetadataView was deleted and the validator has no unused-key check, so they were removed by a byte-identical JSON round-trip.
The pre-cutover SwiftUI card scrolled within a 300pt frame; a long frontmatter must not push the document down.
(inferred — not stated by the author.)| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | docs/agent-notes/scroll-persistence.md | Section still documented the removed isRenderedInDocumentBody filter and nearestRenderedIndex doc comment, contradicting the bugfix report's claim that no agent note recorded the carrier. | Section rewritten to state the invariant the picker now relies on; report corrected. |
| minor | BlockHTMLEmitter.swift markers | data-prism-kind="metadata" had no reader anywhere; prism-metadata-body was styled by nothing (CSS used details.prism-metadata > pre). | Dropped the kind attribute; the body rule now targets .prism-metadata-body so the class has a consumer. |
| minor | document.css summary cursor | cursor: pointer on the card summary is inconsistent with authored <details> summaries, which get none. | Removed. Lifting it into the shared summary rule would not take effect either, since [data-prism-chrome] { cursor: default } outranks a bare element selector. |
| minor | prism/Localizable.xcstrings | Four keys used only by the deleted MetadataView were orphaned and nothing would flag them. | Removed via a JSON round-trip verified byte-identical before editing; diff is 92 deleted lines and nothing else. |
| minor | WebHiddenSectionGuardTests.swift header | File header still listed the hidden frontmatter carrier as a display:none restore target. | Parenthetical removed. |
| nit | Negative hidden-attribute assertions | Two new tests asserted !contains("data-prism-metadata hidden"), which pins attribute order rather than the absence of hidden. | Both now extract the block's opening <section> tag and assert it contains no " hidden" token. |
| nit | docs/accessibility-review.md | Dated review cites MetadataView.swift:50, now deleted. | One-line annotation that the view was removed on 2026-09-07 and where the card lives now. |
| minor | FootnotePopoverView.swift catalogStrings() | renderSettings() resolves catalogStrings() on every body evaluation; this branch adds a seventh String(localized:) the popover can never display. | Pre-existing pattern; the efficiency agent scoped it out of this branch. Candidate follow-up: cache in a static let, which DocumentSession already documents as launch-static. |
Source: local run at 2026-09-07T23:17:36+10:00 · snapshot bab3f6135d1b69d37b9b17d7dcd6c86c5a8f0fcf (dirty working tree)
Baseline: none
Execution: not run · JUnit: none · Coverage: none · Baseline: absent
Coverage scope: as the project configures it
The test runner could not be detected.
Derived by declaration name, from the diff (no baseline run).
Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base b7466591.
prism/Resources/mermaid.min.js — blob over 1 MBClick to expand.
diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex 92a6755d..d0677129 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -354,11 +354,8 @@ nonisolated enum BlockHTMLEmitter { case .htmlComment(let rawText): return emitHTMLComment(rawText: rawText, block: block, domID: domID, context: context) - case .metadata:- // Frontmatter is not rendered in the document body today; emit a hidden,- // inert carrier so the block still has a stable DOM node for identity.- return section(block: block, domID: domID, role: nil,- extraAttributes: ["data-prism-metadata hidden"], inner: "")+ case .metadata(let content):+ return emitMetadata(content: content, block: block, domID: domID, context: context) case .details(let summary, let children, let isOpen, let depth): let model = DetailsModel(summary: summary, children: children, isOpen: isOpen, depth: depth)@@ -851,6 +848,35 @@ nonisolated enum BlockHTMLEmitter { + HTMLEscaping.escapeText(rawText) + "</div>" } + // MARK: YAML frontmatter — collapsed card, expandable in place++ /// Renders the document's YAML frontmatter as a closed `<details>` whose summary is+ /// the catalog-resolved "Metadata" title and whose body is the raw YAML in a+ /// `<pre>` — the pre-cutover `MetadataView` card, rebuilt on the web path.+ ///+ /// The `<details>` is a native disclosure, so it toggles with no JS of its own; the+ /// generic `toggle` listener in prism-theme.js reports it as `detailsToggled` like+ /// any other `<details>`, so its open state lives natively+ /// (`DetailsExpansionCoordinator.openDetailsDOMIDs`) and survives a WebContent+ /// recovery replay. It is never seeded open: the coordinator seeds only from+ /// `.details` blocks' `isOpenByDefault`, so the card starts collapsed.+ ///+ /// `data-prism-metadata` marks the section so prism-notes.js skips it for the+ /// add-note affordance (`supportsNotes` is false for `.metadata`) and the parity+ /// suite's DOM text extraction excludes it. The YAML is escaped text, never parsed,+ /// and carries no source-map runs because nothing anchors to it.+ private static func emitMetadata(+ content: String, block: MarkdownBlock, domID: String, context: Context+ ) -> String {+ let title = HTMLEscaping.escapeText(context.settings.strings.metadataTitle)+ let inner = "<details class=\"prism-metadata\">"+ + "<summary data-prism-chrome>\(title)</summary>"+ + "<pre class=\"prism-metadata-body\"><code>" + HTMLEscaping.escapeText(content) + "</code></pre>"+ + "</details>"+ return section(block: block, domID: domID, role: nil,+ extraAttributes: ["data-prism-metadata"], inner: inner)+ }+ // MARK: Details (task 10) — collapsible; summary is a pure tap target /// The details block's associated values, bundled to keep the emit helper within the
diff --git a/prism/Services/WebRendering/RenderSettings.swift b/prism/Services/WebRendering/RenderSettings.swiftindex b1a3c9a6..82b03e7f 100644--- a/prism/Services/WebRendering/RenderSettings.swift+++ b/prism/Services/WebRendering/RenderSettings.swift@@ -86,6 +86,8 @@ nonisolated struct RenderSettings: Equatable, Sendable { /// label is the only place its text lives, so iOS users have a tap target that /// does not depend on the `contextmenu` gesture (which iOS long-press doesn't fire). var addNote: String+ /// Summary label of the collapsed YAML-frontmatter block at the top of a document.+ var metadataTitle: String /// A neutral default set, intended for tests and previews only. Production /// call sites build this from `String(localized:)` so the catalog stays@@ -102,7 +104,8 @@ nonisolated struct RenderSettings: Equatable, Sendable { followLink: "Follow Link", viewImage: "View Image", grantFolderAccess: "Grant Folder Access",- addNote: "Add note"+ addNote: "Add note",+ metadataTitle: "Metadata" ) }
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex 3671e039..d9f6d84a 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -603,7 +603,9 @@ enum WebDocumentControllerFactory { viewImage: String(localized: "web.image.view", defaultValue: "View Image"), // Reuses the legacy ImageErrorPlaceholder key so existing translations carry over. grantFolderAccess: String(localized: "Grant Folder Access"),- addNote: String(localized: "web.note.addBlock", defaultValue: "Add note to this block")+ addNote: String(localized: "web.note.addBlock", defaultValue: "Add note to this block"),+ // Reuses the pre-cutover MetadataView key so existing translations carry over.+ metadataTitle: String(localized: "Metadata") ) } }
diff --git a/prism/Resources/WebRenderer/document.css b/prism/Resources/WebRenderer/document.cssindex 7fb59126..c6b1715c 100644--- a/prism/Resources/WebRenderer/document.css+++ b/prism/Resources/WebRenderer/document.css@@ -723,6 +723,33 @@ details { } details > *:not(summary) { margin-bottom: var(--prism-spacing); } +/* ---- YAML frontmatter ---- */++/* The document's frontmatter, as a closed disclosure card at the top (the+ * pre-cutover MetadataView). It borrows the code-block surface rather than the+ * author-details tint so it reads as document chrome, not as authored content. The+ * body is capped at the height the old card scrolled within. */+details.prism-metadata {+ padding: 0;+ background-color: var(--prism-code-background);+ border-color: var(--prism-code-border);+}++details.prism-metadata > summary {+ padding: 0.5em 0.9em;+ font-weight: 600;+ color: var(--prism-text-secondary);+}++.prism-metadata-body {+ max-height: 300px;+ margin: 0;+ overflow: auto;+ border: none;+ border-top: 1px solid var(--prism-code-border);+ border-radius: 0 0 8px 8px;+}+ /* ---- HTML comments visibility (Req 1.7) ---- */ /* Comments are always emitted (both states); visibility is an attribute toggle on
diff --git a/prism/Models/MarkdownBlock.swift b/prism/Models/MarkdownBlock.swiftindex 510d9225..b2917fff 100644--- a/prism/Models/MarkdownBlock.swift+++ b/prism/Models/MarkdownBlock.swift@@ -628,37 +628,6 @@ nonisolated enum MarkdownBlock: Identifiable, Equatable, Sendable { } } - /// Whether this block lays out a section the reader can actually be- /// scrolled to in the rendered document body.- ///- /// Only frontmatter is excluded. `BlockHTMLEmitter` emits `.metadata` as an- /// inert `<section … hidden>` identity carrier, so its rect is all zeros and- /// the page's own `bridge.isSectionRendered` predicate skips it — the filter- /// T-1851/T-1944 added to the JS reporting scan and to `scrollToBlock`.- /// Native position pickers need the same filter, or they persist an id the- /// page cannot land on (T-1701).- ///- /// A hidden HTML comment is deliberately NOT excluded: only its inner- /// `.prism-comment` is `display: none`, so the wrapping section is still a- /// full-width block box with a non-zero rect — `isSectionRendered`- /// (`rect.width !== 0 || rect.height !== 0`) counts it as rendered, and the- /// two sides must agree.- ///- /// **Caller contract: filter for collapsed sections first.** This mirrors- /// only the *rect* half of `bridge.isSectionRendered`. The other half —- /// `data-prism-section-hidden`, which `applySectionVisibility` sets on- /// content under a collapsed heading — is not derivable from a block alone,- /// because collapse is session state, not document structure. Callers must- /// therefore start from a collapse-filtered list; today's only caller- /// (`DocumentLayoutCoordinator.applyScrollPositionID`) walks- /// `session.sections.visibleBlocks`, which `SectionCollapseManager` has- /// already filtered. Reaching for this with `session.parsedBlocks` silently- /// loses the collapse half and reopens the disagreement class T-1851 closed.- var isRenderedInDocumentBody: Bool {- if case .metadata = self { return false }- return true- }- /// Debug helper to show block type name. var debugTypeName: String { switch self {
diff --git a/prism/Views/DocumentLayoutCoordinator.swift b/prism/Views/DocumentLayoutCoordinator.swiftindex b7a0e381..97811c33 100644--- a/prism/Views/DocumentLayoutCoordinator.swift+++ b/prism/Views/DocumentLayoutCoordinator.swift@@ -602,15 +602,14 @@ final class DocumentLayoutCoordinator { /// `BlockDOMID`) to `session.scrollPositionID` — the format the web /// document's `scrollToBlock` resolves with `getElementById` (T-1639). /// Returns whether an assignment was made — `false` means there are no- /// visible blocks yet (document still loading), or none of them renders.+ /// visible blocks yet (document still loading). ///- /// The index is advanced past blocks the page does not lay out- /// (`isRenderedInDocumentBody`), applying the same rendered-ness filter as- /// `nearestRenderedSection` in `prism-scroll.js`. Without it, `percentage: 0`- /// on any document with YAML frontmatter selects `visibleBlocks[0]` — the- /// hidden `.metadata` carrier — and persists a position `scrollToBlock`- /// cannot land on (T-1701); the same "persist a block the reader cannot see"- /// defect T-1851 removed from the page's reporting side.+ /// `visibleBlocks` is already collapse-filtered by `SectionCollapseManager`,+ /// and every block kind it can contain lays out a section in the page — the+ /// YAML frontmatter included, since it renders as a collapsed card — so the+ /// picked index is always one `scrollToBlock` can land on. Before the+ /// frontmatter was rendered, `.metadata` was a hidden carrier that had to be+ /// skipped here to match the page's own rendered-ness filter (T-1701/T-1851). @discardableResult private func applyScrollPositionID(percentage: CGFloat, session: DocumentSession) -> Bool {@@ -618,42 +617,13 @@ final class DocumentLayoutCoordinator { guard !visibleBlocks.isEmpty else { return false } let targetIndex = Int(CGFloat(visibleBlocks.count - 1) * percentage) let clampedIndex = max(0, min(targetIndex, visibleBlocks.count - 1))- guard let renderedIndex = Self.nearestRenderedIndex(from: clampedIndex,- in: visibleBlocks) else { return false }- let sourceIndex = visibleBlocks[renderedIndex].sourceIndex+ let sourceIndex = visibleBlocks[clampedIndex].sourceIndex let mapped = BlockDOMID.map(blocks: session.parsedBlocks) guard sourceIndex >= 0, sourceIndex < mapped.count else { return false } session.scrollPositionID = mapped[sourceIndex].domID return true } - /// The nearest index at or after `index` whose block renders in the document- /// body, falling back to the nearest one before it. `nil` only when nothing- /// in the document renders (a frontmatter-only file) — the page reports- /// nothing in that case either, so writing no position is the agreeing- /// outcome, not a missing fallback.- ///- /// Search direction is deliberately the opposite of `nearestRenderedSection`- /// (`prism-scroll.js` looks *backwards* first), and it cannot matter here.- /// The JS goes backwards because its excluded case is content under a- /// collapsed heading, whose closest still-reachable position is the heading- /// above it. Native never sees that case — `visibleBlocks` is already- /// collapse-filtered — so the only kind this predicate excludes is- /// `.metadata`, which `MarkdownBlockParser` only ever emits at index 0. The- /// backward loop is therefore unreachable today and exists purely as a total- /// fallback.- private static func nearestRenderedIndex(from index: Int,- in blocks: [VisibleBlock]) -> Int? {- for candidate in index..<blocks.count where blocks[candidate].block.isRenderedInDocumentBody {- return candidate- }- for candidate in stride(from: index - 1, through: 0, by: -1)- where blocks[candidate].block.isRenderedInDocumentBody {- return candidate- }- return nil- }- /// Scrolls a SwiftUI scroll surface to `targetId` after a short layout /// delay. Used by `RawSourceView` (the only remaining ScrollViewProxy /// surface); the rendered web document scrolls through
diff --git a/prism/Views/MetadataView.swift b/prism/Views/MetadataView.swiftdeleted file mode 100644index 9a1f684d..00000000--- a/prism/Views/MetadataView.swift+++ /dev/null@@ -1,110 +0,0 @@-//-// MetadataView.swift-// prism-//-// Created by Arjen Schwarz on 7/1/2026.-//--import SwiftUI--/// Displays YAML frontmatter metadata in a collapsible card.-///-/// The metadata is hidden by default and can be expanded with a button tap.-/// Similar to the MermaidPlaceholderCard pattern for consistency.-struct MetadataView: View {- let content: String- let reduceMotion: Bool-- @Environment(AppSettings.self) private var settings- @State private var isExpanded = false-- var body: some View {- VStack(alignment: .leading, spacing: 12) {- // Header with expand/collapse button- Button {- toggleExpanded()- } label: {- HStack {- Image(systemName: "doc.text")- .foregroundStyle(.secondary)- Text("Metadata")- .font(.headline)- .foregroundStyle(.primary)- Spacer()- Image(systemName: "chevron.down")- .foregroundStyle(.tertiary)- .rotationEffect(.degrees(isExpanded ? 180 : 0))- }- .contentShape(Rectangle())- }- .buttonStyle(.plain)- .accessibilityLabel(LocalizedStringKey("Document metadata"))- .accessibilityHint(isExpanded ? LocalizedStringKey("Tap to hide metadata") : LocalizedStringKey("Tap to show metadata"))-- // Expandable content- if isExpanded {- metadataContent- }- }- .padding()- .adaptiveMaterialBackground(in: RoundedRectangle(cornerRadius: 12))- .accessibilityElement(children: .contain)- }-- // MARK: - Metadata Content-- @ViewBuilder- private var metadataContent: some View {- ScrollView {- Text(content)- .font(TypographyResolver(from: settings).scaledMonoFont)- .textSelection(.enabled)- .frame(maxWidth: .infinity, alignment: .leading)- }- .frame(maxHeight: 300)- .padding(8)- .background(Color.secondarySystemBackground)- .clipShape(RoundedRectangle(cornerRadius: 8))- .accessibilityLabel(LocalizedStringKey("Metadata content"))- .accessibilityValue(content)- }-- // MARK: - Actions-- private func toggleExpanded() {- if reduceMotion {- isExpanded.toggle()- } else {- withAnimation(.easeInOut(duration: 0.25)) {- isExpanded.toggle()- }- }- }-}--#Preview("Collapsed") {- MetadataView(- content: """- title: Example Document- author: John Doe- date: 2026-01-07- tags:- - swift- - markdown- """,- reduceMotion: false- )- .padding()-}--#Preview("Expanded") {- MetadataView(- content: """- title: Example Document- author: John Doe- date: 2026-01-07- """,- reduceMotion: false- )- .padding()-}
diff --git a/prism/Resources/WebRenderer/prism-bridge.js b/prism/Resources/WebRenderer/prism-bridge.jsindex d5e8e900..46c7f64e 100644--- a/prism/Resources/WebRenderer/prism-bridge.js+++ b/prism/Resources/WebRenderer/prism-bridge.js@@ -97,11 +97,11 @@ // misreads: a top of 0 wins the "closest to the viewport top" contest // (T-1851), passes both viewport-window bounds (search windowing), reads as // "already on screen" (the reveal scroll), and scrollIntoView on it no-ops- // (restore). Two kinds of section are display:none — content under a- // collapsed heading (data-prism-section-hidden, hidden by document.css) and- // the inert YAML-frontmatter carrier emitted with the bare `hidden`- // attribute. This is the SINGLE shared predicate; readers must route through- // it rather than repeating the rect test per site.+ // (restore). Content under a collapsed heading is display:none+ // (data-prism-section-hidden, hidden by document.css); the YAML-frontmatter+ // block used to be a second kind, emitted as an inert `hidden` carrier, until+ // it was rendered as a collapsed card. This is the SINGLE shared predicate;+ // readers must route through it rather than repeating the rect test per site. // // The rect test MUST stay a conjunction (width === 0 AND height === 0): // empty sections, hidden HTML comments, closed details, and floated-only
diff --git a/prism/Resources/WebRenderer/prism-scroll.js b/prism/Resources/WebRenderer/prism-scroll.jsindex f20e7120..a7ffc2cf 100644--- a/prism/Resources/WebRenderer/prism-scroll.js+++ b/prism/Resources/WebRenderer/prism-scroll.js@@ -71,10 +71,11 @@ // "closest to the viewport top" test and beats every genuinely visible section, // which starts ABOVE the fold at a negative top — so without a guard a // non-laid-out section wins and Prism persists a block the reader cannot see as- // the reading position (T-1851): content under a collapsed heading, and the- // inert frontmatter carrier (sections[0] in any document with frontmatter,- // which used to win at EVERY scroll offset). Skip anything that is not laid- // out, in the scan AND in the fallback, via the shared predicate+ // the reading position (T-1851): content under a collapsed heading, and —+ // before it was rendered as a collapsed card — the inert frontmatter carrier+ // (sections[0] in any document with frontmatter, which used to win at EVERY+ // scroll offset). Skip anything that is not laid out, in the scan AND in the+ // fallback, via the shared predicate // bridge.isSectionRendered (T-1944) — the layout rationale and the // conjunction/attribute trade-offs are documented on the predicate. //@@ -99,12 +100,12 @@ } } // If nothing is above the fold yet, use the first RENDERED section (before- // T-1851 this was sections[0], which is the hidden frontmatter carrier in- // any document with frontmatter). When NOTHING is rendered — a- // frontmatter-only document, or a zero-size viewport — this deliberately- // returns null and reportVisibleBlock posts nothing, keeping the last known- // position rather than persisting an unreachable id. Silence is the intended- // behaviour there, not a missing fallback.+ // T-1851 this was sections[0], which at the time was the hidden frontmatter+ // carrier in any document with frontmatter). When NOTHING is rendered — a+ // zero-size viewport — this deliberately returns null and+ // reportVisibleBlock posts nothing, keeping the last known position rather+ // than persisting an unreachable id. Silence is the intended behaviour+ // there, not a missing fallback. if (!best) { best = firstRendered; } return best; }@@ -305,8 +306,8 @@ // Captures the reader's position in the layout that is about to be replaced. // Uses the SAME topmost-block scan the report uses, so it inherits the- // rendered-ness filter (T-1851/T-1944) for free: a collapsed section or the- // hidden frontmatter carrier can never become the anchor.+ // rendered-ness filter (T-1851/T-1944) for free: a collapsed section can+ // never become the anchor. function captureReflowAnchor() { // A window is already open, so an anchor is already held: KEEP IT. The // reader has not moved since it was taken (reporting is suppressed and any@@ -452,9 +453,9 @@ // The nearest laid-out section to a display:none target (T-1944): // preceding first — for content under a collapsed heading that is the // heading that hides it, the closest the stored reading position can still- // be — else following (the frontmatter carrier is sections[0], so a stale- // pre-T-1851 stored id falls back to the first rendered section, the- // document top). Null when nothing is rendered at all.+ // be — else following (a stale id stored for a section that no longer lays+ // out falls back to the first rendered section after it). Null when nothing+ // is rendered at all. function nearestRenderedSection(element) { var sections = document.querySelectorAll("section[data-prism-block-id]"); var index = -1;@@ -487,9 +488,8 @@ var element = document.getElementById(domID); if (!element) { return false; } // scrollIntoView on a display:none element does NOTHING, so a restore- // (or navigation) into a since-collapsed block or the hidden- // frontmatter carrier used to silently lose the position (T-1944).- // Land on the nearest rendered section instead.+ // (or navigation) into a since-collapsed block used to silently lose+ // the position (T-1944). Land on the nearest rendered section instead. if (!bridge.isSectionRendered(element)) { element = nearestRenderedSection(element); if (!element) { return false; }
diff --git a/prism/Localizable.xcstrings b/prism/Localizable.xcstringsindex 1c27fbcf..8a395750 100644--- a/prism/Localizable.xcstrings+++ b/prism/Localizable.xcstrings@@ -1427,29 +1427,6 @@ } } },- "Document metadata": {- "extractionState": "manual",- "localizations": {- "en": {- "stringUnit": {- "state": "translated",- "value": "Document metadata"- }- },- "en-GB": {- "stringUnit": {- "state": "translated",- "value": "Document metadata"- }- },- "en-US": {- "stringUnit": {- "state": "translated",- "value": "Document metadata"- }- }- }- }, "Document notes banner placement": { "extractionState": "manual", "localizations": {@@ -2715,29 +2692,6 @@ } } },- "Metadata content": {- "extractionState": "manual",- "localizations": {- "en": {- "stringUnit": {- "state": "translated",- "value": "Metadata content"- }- },- "en-GB": {- "stringUnit": {- "state": "translated",- "value": "Metadata content"- }- },- "en-US": {- "stringUnit": {- "state": "translated",- "value": "Metadata content"- }- }- }- }, "Name": { "extractionState": "manual", "localizations": {@@ -5084,52 +5038,6 @@ } } },- "Tap to hide metadata": {- "extractionState": "manual",- "localizations": {- "en": {- "stringUnit": {- "state": "translated",- "value": "Tap to hide metadata"- }- },- "en-GB": {- "stringUnit": {- "state": "translated",- "value": "Tap to hide metadata"- }- },- "en-US": {- "stringUnit": {- "state": "translated",- "value": "Tap to hide metadata"- }- }- }- },- "Tap to show metadata": {- "extractionState": "manual",- "localizations": {- "en": {- "stringUnit": {- "state": "translated",- "value": "Tap to show metadata"- }- },- "en-GB": {- "stringUnit": {- "state": "translated",- "value": "Tap to show metadata"- }- },- "en-US": {- "stringUnit": {- "state": "translated",- "value": "Tap to show metadata"- }- }- }- }, "Text Size": { "extractionState": "manual", "localizations": {
diff --git a/prismTests/WebRendering/BlockHTMLEmitterTests.swift b/prismTests/WebRendering/BlockHTMLEmitterTests.swiftindex 984cef92..ff2e9794 100644--- a/prismTests/WebRendering/BlockHTMLEmitterTests.swift+++ b/prismTests/WebRendering/BlockHTMLEmitterTests.swift@@ -229,6 +229,28 @@ struct BlockHTMLEmitterStructureTests { #expect(!on.html.contains("prism-comment-hidden")) } + @Test("YAML frontmatter emits a closed <details> card with the escaped YAML in a <pre>")+ func metadataCard() {+ let yaml = "title: A & B\ntags:\n - <swift>"+ let doc = BlockHTMLEmitterTestSupport.emit([.metadata(content: yaml)])++ // A rendered, collapsed disclosure — not the pre-restore hidden carrier. The+ // opening <section> tag is inspected as a whole so a reordered `hidden` cannot+ // slip past a substring check.+ let sectionTag = doc.html.components(separatedBy: "<section ")+ .first { $0.contains("data-prism-metadata") }+ .map { String($0.prefix { $0 != ">" }) }+ #expect(sectionTag != nil, "the frontmatter section must carry data-prism-metadata")+ #expect(sectionTag?.contains(" hidden") == false, "the frontmatter section must lay out")+ #expect(doc.html.contains("<details class=\"prism-metadata\">"), "must start collapsed")+ #expect(!doc.html.contains("<details class=\"prism-metadata\" open"))+ #expect(doc.html.contains("<summary data-prism-chrome>Metadata</summary>"))++ // The YAML is escaped text inside <pre><code>, never parsed as markdown or HTML.+ #expect(doc.html.contains("<pre class=\"prism-metadata-body\"><code>title: A & B\ntags:\n - <swift></code></pre>"))+ #expect(!doc.html.contains("<swift>"))+ }+ @Test("Inline HTML comment emits a toggleable annotation span, not dropped (T-1638, Req 3.2)") func inlineHTMLComment() { let doc = BlockHTMLEmitterTestSupport.emit([@@ -452,6 +474,11 @@ struct BlockHTMLEmitterLocalisationTests { let image = BlockHTMLEmitterTestSupport.emit([.image(source: "a.png", alt: "a")], settings: settings) #expect(image.html.contains("IMG_ERR_SENTINEL"))++ strings.metadataTitle = "META_SENTINEL"+ let metadata = BlockHTMLEmitterTestSupport.emit([.metadata(content: "title: x")],+ settings: RenderSettings(strings: strings))+ #expect(metadata.html.contains("<summary data-prism-chrome>META_SENTINEL</summary>")) } } @@ -513,10 +540,10 @@ struct BlockHTMLEmitterTotalityTests { for block in Self.allVariants { let doc = BlockHTMLEmitterTestSupport.emit([block]) let text = doc.normalisedText- // Pick a representative visible token per block; metadata/thematicBreak/image- // have no body text so are skipped for the token check.+ // Pick a representative visible token per block; thematicBreak/image have no+ // body text so are skipped for the token check. switch block {- case .thematicBreak, .metadata, .image:+ case .thematicBreak, .image: #expect(doc.html.contains("data-prism-block-id")) default: let token = block.textContent
diff --git a/prismTests/WebRendering/WebCollapsedSectionScrollTests.swift b/prismTests/WebRendering/WebCollapsedSectionScrollTests.swiftindex 9965295d..3553e8d0 100644--- a/prismTests/WebRendering/WebCollapsedSectionScrollTests.swift+++ b/prismTests/WebRendering/WebCollapsedSectionScrollTests.swift@@ -10,10 +10,11 @@ // genuinely visible section scrolled above the fold (negative top). Prism then // persists that block as the reading position and the restore either lands on the // wrong block or silently no-ops (scrollIntoView on a display:none element does-// nothing). Two kinds of section are display:none: content under a collapsed-// heading (`prism-theme.js` marks it `data-prism-section-hidden="true"`,-// document.css hides it) and the inert YAML-frontmatter carrier that-// `BlockHTMLEmitter` emits with the bare `hidden` attribute.+// nothing). Content under a collapsed heading is display:none (`prism-theme.js`+// marks it `data-prism-section-hidden="true"`, document.css hides it). The+// YAML-frontmatter block used to be a second kind — an inert carrier emitted+// with the bare `hidden` attribute — and is now a rendered, collapsed card; the+// last test pins that it is reported like any other section. // // All four tests drive a real WebPage with the bundled document.css injected, so // the real cascade — not a hand-rolled inline style — governs visibility.@@ -213,18 +214,15 @@ struct WebCollapsedSectionScrollTests { #expect(reported == targetID, "a minimal visible section was skipped; reported \(reported)") } - // The collapse-free instance of the same defect, and the only case that- // exercises the fallback change (`sections[0]` → the first *rendered*- // section). `BlockHTMLEmitter` emits a `.metadata` (YAML frontmatter) block as- // an inert `<section … data-prism-metadata hidden>` carrier so the block keeps- // a stable DOM node. Nothing in document.css overrides `[hidden]`, so the UA- // rule makes it display:none — an all-zero rect on `sections[0]`, carrying no- // `data-prism-section-hidden`. Before the fix its top of 0 won the contest at- // EVERY scroll offset, so any document with frontmatter always reported the- // frontmatter carrier as the reading position and the restore silently- // no-opped. Only the zero-rect half of the filter catches this one.- @Test("visibleBlock never reports the hidden frontmatter carrier")- func frontmatterCarrierIsNotReportedAsVisibleBlock() async throws {+ // The YAML-frontmatter block. `BlockHTMLEmitter` used to emit `.metadata` as+ // an inert `<section … data-prism-metadata hidden>` carrier — display:none by+ // the UA `[hidden]` rule, so an all-zero rect on `sections[0]` whose top of 0+ // won the contest at EVERY scroll offset (T-1851). It now renders as a closed+ // `<details>` card, so it lays out like any other section: it is the reading+ // position at the top of the document, and it never beats a section the+ // reader has actually scrolled to.+ @Test("visibleBlock reports the rendered frontmatter card like any other section")+ func frontmatterCardIsReportedAsVisibleBlock() async throws { var blocks: [MarkdownBlock] = [ .metadata(content: "title: Document"), .heading(level: 1, text: "Document"),@@ -235,20 +233,15 @@ struct WebCollapsedSectionScrollTests { let harness = try await WebDocumentLiveHarness.makeStyled(blocks: blocks) let ids = domIDs(blocks) - // No collapse anywhere — the carrier is hidden by the UA `[hidden]` rule,- // not by `data-prism-section-hidden`.+ // No collapse anywhere, and nothing is display:none — the card lays out. #expect(try await hiddenSectionCount(harness) == 0, "no section should be marked collapsed here")- #expect(- try await zeroRectSectionIDs(harness) == [ids[0]],- "the frontmatter carrier should be the only zero-rect section"- )+ #expect(try await zeroRectSectionIDs(harness).isEmpty, "the frontmatter card must lay out") - // At the top of the document the fallback must pick the first RENDERED- // section (the H1), not the hidden carrier.+ // At the top of the document the frontmatter card IS the reading position. let atTop = try #require( try await waitForVisibleBlock(harness, after: 0), "no visibleBlock was reported at the top" )- #expect(atTop == ids[1], "the hidden frontmatter carrier was reported at scroll 0: \(atTop)")+ #expect(atTop == ids[0], "the rendered frontmatter card was not reported at scroll 0: \(atTop)") // And after scrolling, the genuinely visible section must win the scan. let targetID = ids[20]@@ -262,6 +255,6 @@ struct WebCollapsedSectionScrollTests { let reported = try #require( try await waitForVisibleBlock(harness, after: seen), "no visibleBlock was reported after scrolling" )- #expect(reported == targetID, "the hidden frontmatter carrier beat a visible section: \(reported)")+ #expect(reported == targetID, "the frontmatter card beat a visible section: \(reported)") } }
diff --git a/prismTests/WebRendering/WebHiddenSectionGuardTests.swift b/prismTests/WebRendering/WebHiddenSectionGuardTests.swiftindex 3a701299..3a9856ef 100644--- a/prismTests/WebRendering/WebHiddenSectionGuardTests.swift+++ b/prismTests/WebRendering/WebHiddenSectionGuardTests.swift@@ -17,8 +17,7 @@ // known-open item recorded in specs/search decision 8 (T-1918). // 2. prism-scroll.js `scrollToBlock` (the restore/navigation command): // `scrollIntoView` on a display:none element does nothing, so a restore-// targeting a block the user has since collapsed (or the hidden-// frontmatter carrier persisted by the pre-T-1851 bug) silently no-ops.+// targeting a block the user has since collapsed silently no-ops. // // The fix hoists one predicate — `bridge.isSectionRendered(section)` — and // routes scroll reporting, search windowing, the reveal scroll, and the restore@@ -464,12 +463,15 @@ struct WebHiddenSectionGuardTests { } // The stale-stored-id shape of the same defect: the pre-T-1851 bug persisted- // the hidden frontmatter carrier (sections[0], `hidden` attribute, no- // data-prism-section-hidden) as the reading position at every scroll offset.- // Restoring such an id must fall back to the first rendered section (the- // document top) instead of silently keeping whatever position the page is at.- @Test("A restore targeting the hidden frontmatter carrier falls back to the document top")- func restoreToFrontmatterCarrierFallsBackToTheTop() async throws {+ // the then-hidden frontmatter carrier (sections[0], `hidden` attribute, no+ // data-prism-section-hidden) as the reading position at every scroll offset,+ // and restoring such an id had to fall back to the first rendered section.+ // The frontmatter now renders as a closed card at the document top, so a+ // stored id naming it — including one persisted before the card existed — is+ // a position the page lands on directly, rather than silently keeping+ // whatever position the page is at.+ @Test("A restore targeting the frontmatter card lands on it at the document top")+ func restoreToFrontmatterCardLandsAtTheTop() async throws { var blocks: [MarkdownBlock] = [ .metadata(content: "title: Document"), .heading(level: 1, text: "Document"),@@ -497,11 +499,11 @@ struct WebHiddenSectionGuardTests { try await harness.send(.scrollToBlock(domID: ids[0])) try await Task.sleep(for: .milliseconds(300)) - // Expected: the fallback lands on the first rendered section — the- // document top, modulo the document's own top padding. Actual (bug):- // scrollIntoView on the [hidden] carrier does nothing and the page- // stays where the setup scroll left it.+ // Expected: the page lands on the card — the document top, modulo the+ // document's own top padding. The pre-fix failure was scrollIntoView on+ // a [hidden] carrier doing nothing, leaving the page where the setup+ // scroll left it. let restored = try await scrollY(harness)- #expect(restored < 100, "the carrier restore must fall back to the document top, got scrollY \(restored)")+ #expect(restored < 100, "the frontmatter restore must land at the document top, got scrollY \(restored)") } }
diff --git a/prismTests/WebRendering/WebScrollPositionRetentionTests.swift b/prismTests/WebRendering/WebScrollPositionRetentionTests.swiftindex d7dcc8dd..80fe31b8 100644--- a/prismTests/WebRendering/WebScrollPositionRetentionTests.swift+++ b/prismTests/WebRendering/WebScrollPositionRetentionTests.swift@@ -492,11 +492,11 @@ struct WebScrollPositionRetentionTests { #expect(log.settled) } - // A document whose first block is the YAML-frontmatter carrier. The emitter- // renders `.metadata` as `<section … hidden>`, so it is `visibleBlocks[0]`- // natively but has an all-zero rect in the page — exactly the block- // `bridge.isSectionRendered` filters out of the JS reporting scan- // (T-1851/T-1944).+ // A document whose first block is the YAML frontmatter. The emitter renders+ // `.metadata` as a closed `<details>` card, so it is `visibleBlocks[0]`+ // natively AND lays out in the page. (It used to be an inert+ // `<section … hidden>` carrier with an all-zero rect, which the native+ // percentage→block picker had to skip to agree with the page, T-1701.) private func makeFrontmatterSession() async -> DocumentSession { let markdown = """ ---@@ -519,13 +519,11 @@ struct WebScrollPositionRetentionTests { } // The native percentage→block picker must agree with the JS one about which- // sections exist for the reader. Writing the hidden frontmatter carrier's id- // persists a position `scrollToBlock` cannot land on — the same "persist a- // block the reader cannot see" defect T-1851 removed from the reporting- // side. The page only looks right by accident, because T-1944's JS fallback- // redirects the unresolvable target to the nearest rendered section.- @Test("raw→rendered at the top skips the hidden frontmatter carrier")- func rawToRenderedAtTopSkipsFrontmatterCarrier() async {+ // sections exist for the reader. The frontmatter card lays out, so at the+ // top of the document it IS the reading position: the picker writes its id,+ // and the page emits it as a section that is not hidden.+ @Test("raw→rendered at the top lands on the rendered frontmatter card")+ func rawToRenderedAtTopLandsOnFrontmatterCard() async { let session = await makeFrontmatterSession() let coordinator = DocumentLayoutCoordinator() @@ -535,7 +533,7 @@ struct WebScrollPositionRetentionTests { guard case .metadata = first?.block else { return false } return true }()- #expect(firstIsMetadata, "expected the frontmatter carrier to be visibleBlocks[0]")+ #expect(firstIsMetadata, "expected the frontmatter block to be visibleBlocks[0]") // Rendered → raw, then a deliberate scroll to the top of the raw source. coordinator.toggleRawSource(session: session)@@ -549,15 +547,18 @@ struct WebScrollPositionRetentionTests { mapped.indices.contains(block.sourceIndex) ? mapped[block.sourceIndex].domID : nil } #expect(metadataID != nil)- #expect(session.scrollPositionID != metadataID)+ #expect(session.scrollPositionID == metadataID) - // Strongest form: the id landed on is a section the page really emits- // (`rawToRenderedWritesResolvableDOMId`'s check, on a document where the- // resolvable-but-hidden carrier would otherwise pass it).+ // The id landed on is a section the page really emits, and one that lays+ // out: the frontmatter section's opening tag carries no `hidden` token. let emitted = BlockHTMLEmitter.emit( blocks: session.parsedBlocks, footnotes: .empty, settings: RenderSettings() )- #expect(emitted.html.contains("id=\"\(session.scrollPositionID)\""))+ let sectionTag = emitted.html.components(separatedBy: "<section ")+ .first { $0.contains("id=\"\(session.scrollPositionID)\"") }+ .map { String($0.prefix { $0 != ">" }) }+ #expect(sectionTag != nil, "the landed-on id must be a section the page emits")+ #expect(sectionTag?.contains(" hidden") == false, "the frontmatter section must not be hidden") } // MARK: - Symptom 2: reopen lands at top
diff --git a/prismTests/WebRendering/WebParityFixtureTests.swift b/prismTests/WebRendering/WebParityFixtureTests.swiftindex b99911fc..832b7915 100644--- a/prismTests/WebRendering/WebParityFixtureTests.swift+++ b/prismTests/WebRendering/WebParityFixtureTests.swift@@ -111,7 +111,7 @@ struct WebParityFixtureTests { "html-comment": ["data-prism-comment"], "details": ["<details", "<summary", "prism-details-body"], "footnotes": ["prism-footnote-badge", "href=\"prism://footnote/"],- "metadata": ["data-prism-metadata"],+ "metadata": ["data-prism-metadata", "<details class=\"prism-metadata\">", "prism-metadata-body"], "combination": ["<h1>", "<table>", "<details", "prism-footnote-badge", "<pre>"], ] for (name, markers) in expectations {
diff --git a/docs/agent-notes/scroll-persistence.md b/docs/agent-notes/scroll-persistence.mdindex cd11c192..390ad9ea 100644--- a/docs/agent-notes/scroll-persistence.md+++ b/docs/agent-notes/scroll-persistence.md@@ -383,23 +383,23 @@ built outside a `ScrollViewReader`. What is *not* pinned is the handful of wirin lines in the view that call them (including the `.onDisappear` cancel itself). Keep behaviour out of those lines. -### Native Position Pickers Need the Rendered-ness Filter Too--`applyScrollPositionID` advances past blocks the page does not lay out-(`MarkdownBlock.isRenderedInDocumentBody`), applying the same rendered-ness filter-as `nearestRenderedSection` in `prism-scroll.js` (search direction differs and-cannot matter — see the doc comment on `nearestRenderedIndex`). `isRenderedInDocumentBody`-mirrors only the *rect* half of `bridge.isSectionRendered`; the-`data-prism-section-hidden` half is the caller's job, which is why the caller-starts from the collapse-filtered `session.sections.visibleBlocks`. Without the-filter `percentage: 0` selects `visibleBlocks[0]`, which on-any document with YAML frontmatter is the hidden `.metadata` carrier — an id-`scrollToBlock` cannot land on, persisted to `ScrollPositionStore` at the next-close. The page only looked right by accident, via T-1944's JS-side redirect. Note-the asymmetry with hidden HTML comments: those are **not** skipped, because only-the inner `.prism-comment` is `display: none` — the wrapping section keeps a-full-width box, so `bridge.isSectionRendered` (`rect.width !== 0 || rect.height !== 0`)-counts it as rendered and native must agree.+### Native Position Pickers Must Agree With the Page About What Lays Out++`applyScrollPositionID` picks `visibleBlocks[clampedIndex]` directly. That is+only correct because both halves of `bridge.isSectionRendered` are already+satisfied by construction: the `data-prism-section-hidden` half because+`session.sections.visibleBlocks` is collapse-filtered, and the rect half because+every block kind the emitter produces now lays out a section — including YAML+frontmatter, which renders as a closed `<details>` card. Until 2026-09-07 the+emitter wrote `.metadata` as an inert `<section hidden>` carrier, so `percentage: 0`+on a frontmatter document selected an id `scrollToBlock` could not land on, and+the picker carried a `MarkdownBlock.isRenderedInDocumentBody` filter to skip it+(T-1701). If a block kind ever becomes display:none in the page again, that+filter has to come back on the native side, or the two sides disagree and the+page only looks right via T-1944's JS-side redirect. Hidden HTML comments are+not such a case: only the inner `.prism-comment` is `display: none`, the+wrapping section keeps a full-width box, and `bridge.isSectionRendered`+(`rect.width !== 0 || rect.height !== 0`) counts it as rendered. ## Raw-View Scroll Restoration Drives Both Scroll APIs With a Delay
diff --git a/docs/accessibility-review.md b/docs/accessibility-review.mdindex 62287a6c..8b9c0071 100644--- a/docs/accessibility-review.md+++ b/docs/accessibility-review.md@@ -71,7 +71,7 @@ Hardcoded point sizes that bypassed Dynamic Type, now routed through the central ### H4 — Reduce Transparency is unimplemented everywhere (T-1044) **Files**: 9 sites use `.regularMaterial`, `.ultraThinMaterial`, or `.glassEffect(.regular.interactive())` — none read `accessibilityReduceTransparency`. Examples:-- `prism/Views/MetadataView.swift:50`+- `prism/Views/MetadataView.swift:50` (view removed 2026-09-07; the frontmatter card now renders in the web document via `BlockHTMLEmitter`) - `prism/Views/MermaidPreviewCard.swift:180, 189` - `prism/Views/InlineSearchBar.swift`, `ToastNotification.swift`, `ReloadBanner.swift`, `FootnotePopoverView.swift`, `NotePopover.swift`, `ImageDetailWindow.swift`, `AccessibleTableView.swift`
diff --git a/specs/bugfixes/frontmatter-card-missing-on-web-path/report.md b/specs/bugfixes/frontmatter-card-missing-on-web-path/report.mdnew file mode 100644index 00000000..f086f16f--- /dev/null+++ b/specs/bugfixes/frontmatter-card-missing-on-web-path/report.md@@ -0,0 +1,127 @@+# Bugfix Report: Frontmatter Card Missing on the Web Rendering Path++**Date:** 2026-09-07+**Status:** Fixed++## Description of the Issue++Before the WebKit rendering cutover (T-1542), a document that opened with YAML frontmatter showed a collapsed "Metadata" card above its first heading. Tapping the card expanded it in place to reveal the raw YAML. After the cutover the card was gone: a document with frontmatter simply started at its first heading, with no indication that the frontmatter existed and no way to read it short of switching to the raw source view.++**Reproduction steps:**+1. Open any markdown file that starts with a `---` frontmatter block (for example `samples/Parity/metadata.md`).+2. Look above the first heading.+3. Observe that nothing is rendered there — no card, no disclosure, no metadata.++**Impact:** Every document with YAML frontmatter, on both iOS and macOS, since the cutover shipped. Reading was not blocked, but a feature the app previously had disappeared silently, and metadata such as title, author, tags and dates was unreachable from the rendered view.++## Investigation Summary++The parser was the first suspect and was quickly cleared: `MarkdownBlockParser.parseWithFootnotes` still extracts the frontmatter and prepends a `.metadata(content:)` block, and the model, search, notes and parser tests all still exercise that block. The defect was therefore on the rendering side.++- **Symptoms examined:** No frontmatter card in the rendered document; raw source view still shows the frontmatter; the document's first visible block in the native section model is still the `.metadata` block.+- **Code inspected:** `BlockHTMLEmitter.emit`'s `.metadata` branch; the pre-cutover `MetadataView`; every consumer of the emitted section (`prism-bridge.js`'s `isSectionRendered`, `prism-scroll.js`'s reporting scan and `scrollToBlock` fallback, `prism-notes.js`'s affordance loop, `DocumentLayoutCoordinator.applyScrollPositionID`, `MarkdownBlock.isRenderedInDocumentBody`); the `<details>` emit path and the `detailsToggled` round trip through `DetailsExpansionCoordinator`; the localisation seam in `RenderSettings.Strings`.+- **Hypotheses tested:** (1) Frontmatter extraction had regressed — ruled out, the block is parsed. (2) The emitter rendered the block but CSS hid it — ruled out, the emitter wrote an empty section with a bare `hidden` attribute. (3) Rendering had been deliberately deferred with a follow-up ticket — no ticket or spec entry records one, and the one agent note that mentioned the carrier (`docs/agent-notes/scroll-persistence.md`) documented the filters built around it, not a plan to render it; the emitter comment reads "not rendered in the document body today", which is a deferral that was never picked up.++## Discovered Root Cause++The `.metadata` branch of `BlockHTMLEmitter.emit` emitted `<section data-prism-metadata hidden></section>`: an empty, display-none carrier whose only purpose was to give the block a stable DOM node for identity. `MetadataView`, the SwiftUI card that had rendered the frontmatter, was never wired to the web path and became dead code the moment the SwiftUI in-flow renderer was retired.++**Defect type:** Dropped feature during a renderer migration (missing implementation, not a logic error).++**Why it occurred:** The cutover was scoped around parity of the document body's prose blocks, and the emitter branch was written as a placeholder so the block-identity machinery (DOM ids, scroll positions, notes) stayed total across every `MarkdownBlock` case. The placeholder was then hardened rather than replaced: T-1851, T-1944 and T-1701 each added filters so that the hidden carrier could not become the persisted reading position, which made the "hidden" shape look like a deliberate design rather than an unfinished one.++**Contributing factors:** The parity fixture suite compared reader-visible text and deliberately excluded `[data-prism-metadata]` from both sides, so no parity test could notice the card was absent. No UI test covered the card either.++## Resolution for the Issue++**Changes made:**+- `prism/Services/WebRendering/BlockHTMLEmitter.swift:357` and `:868` - `.metadata` now emits a closed `<details class="prism-metadata">` inside its section, with a `<summary>` carrying the catalog-resolved title and a `<pre><code>` body holding the escaped YAML. The section keeps `data-prism-metadata`, the one marker with consumers (`prism-notes.js` and the parity text extractor).+- `prism/Services/WebRendering/RenderSettings.swift:90` - `Strings.metadataTitle` added, with the fallback "Metadata".+- `prism/ViewModels/WebDocumentControllerFactory.swift:608` - `catalogStrings()` resolves the title from the existing "Metadata" catalog key, so the pre-cutover translations carry over.+- `prism/Resources/WebRenderer/document.css:732` - Card styling: code-block surface and border, bold secondary summary, body capped at 300px with its own scrolling, matching the old card's 300pt cap.+- `prism/Models/MarkdownBlock.swift` - `isRenderedInDocumentBody` removed; every block now lays out.+- `prism/Views/DocumentLayoutCoordinator.swift:614` - `applyScrollPositionID` no longer skips past unrendered blocks; `nearestRenderedIndex` removed.+- `prism/Resources/WebRenderer/prism-bridge.js`, `prism-scroll.js` - Comments that described the hidden carrier updated; no behaviour change, the rendered-ness predicate now simply stops excluding the block because its rect is non-zero.+- `prism/Views/MetadataView.swift` - Deleted; it had no callers since the cutover.+- `CHANGELOG.md` - Entry under `[Unreleased] / Fixed`.++**Approach rationale:** A native `<details>` is the smallest thing that gives back the old behaviour. It needs no JavaScript of its own, the summary is natively focusable and keyboard-activatable (the T-1725 rule that every interactive chrome element is a real control), and the existing `toggle` listener in `prism-theme.js` already reports any `<details>` toggle as `detailsToggled`. That means the card's open state lives in `DetailsExpansionCoordinator.openDetailsDOMIDs` for free and is replayed after a WebContent recovery, without touching the coordinator. The card starts collapsed because the coordinator only seeds from `.details` blocks' `isOpenByDefault`.++Once the block lays out, the native and page-side filters that skipped the carrier become vacuous. Removing them rather than leaving an always-true predicate keeps the picker and its documentation honest: `applyScrollPositionID` now says every visible block is landable, which is true.++**Alternatives considered:**+- Render the card with dedicated JavaScript and a custom toggle button — rejected; duplicates what `<details>` and the existing toggle listener already provide, and adds a second focus and accessibility surface to keep in sync.+- Render the YAML as a code block with `language-yaml` and highlight.js — rejected; the old card showed plain monospaced text, and syntax colouring would make the card louder than the content it sits above. Easy to add later if wanted.+- Keep `isRenderedInDocumentBody` returning `true` for every case — rejected; a predicate that cannot be false is dead code and its doc comment would have to describe a carrier that no longer exists.+- Exclude the card from the reading-position scan so a restore never lands on it — rejected; the card is genuinely visible at the top of the document, so landing on it is the correct position, and the previous exclusion existed only because the carrier had no layout.++## Regression Test++**Test file:** `prismTests/WebRendering/BlockHTMLEmitterTests.swift`+**Test name:** `BlockHTMLEmitterStructureTests/metadataCard`++**What it verifies:** The `.metadata` block emits a section that is not hidden, containing a closed `<details class="prism-metadata">`, a summary with the title, and the YAML escaped inside `<pre><code>` (ampersands and angle brackets must not survive as markup).++Companion tests, each rewritten from a test that previously pinned the hidden carrier:+- `BlockHTMLEmitterLocalisationTests/usesInjectedStrings` — the summary title comes from `RenderSettings.Strings.metadataTitle`, not a literal.+- `WebCollapsedSectionScrollTests/frontmatterCardIsReportedAsVisibleBlock` (live WebKit) — the card has a non-zero rect and is reported as the reading position at the top of the document.+- `WebHiddenSectionGuardTests/restoreToFrontmatterCardLandsAtTheTop` (live WebKit) — a stored position naming the card scrolls the page to the top.+- `WebScrollPositionRetentionTests/rawToRenderedAtTopLandsOnFrontmatterCard` — the native raw-to-rendered picker writes the card's DOM id at percentage zero and the emitted section carries no `hidden` attribute.+- `WebParityFixtureTests` — the `metadata` fixture must contain the details element and the body class.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism -destination 'platform=macOS' \+ -only-testing:prismTests/BlockHTMLEmitterStructureTests \+ -only-testing:prismTests/WebCollapsedSectionScrollTests \+ -only-testing:prismTests/WebHiddenSectionGuardTests \+ -only-testing:prismTests/WebScrollPositionRetentionTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/WebRendering/BlockHTMLEmitter.swift` | `.metadata` emits a closed details card instead of a hidden carrier |+| `prism/Services/WebRendering/RenderSettings.swift` | `Strings.metadataTitle` added |+| `prism/ViewModels/WebDocumentControllerFactory.swift` | Catalog resolution of the title |+| `prism/Resources/WebRenderer/document.css` | Card styling |+| `prism/Models/MarkdownBlock.swift` | `isRenderedInDocumentBody` removed |+| `prism/Views/DocumentLayoutCoordinator.swift` | `nearestRenderedIndex` removed, picker simplified |+| `prism/Resources/WebRenderer/prism-bridge.js` | Comment update |+| `prism/Resources/WebRenderer/prism-scroll.js` | Comment updates |+| `prism/Views/MetadataView.swift` | Deleted (dead since the cutover) |+| `prismTests/WebRendering/BlockHTMLEmitterTests.swift` | New `metadataCard` test; localisation sentinel; metadata joins the content-survives check |+| `prismTests/WebRendering/WebCollapsedSectionScrollTests.swift` | Carrier test inverted to pin the rendered card |+| `prismTests/WebRendering/WebHiddenSectionGuardTests.swift` | Restore test reframed for the rendered card |+| `prismTests/WebRendering/WebScrollPositionRetentionTests.swift` | Picker test inverted to pin landing on the card |+| `prismTests/WebRendering/WebParityFixtureTests.swift` | Structural markers for the metadata fixture |+| `CHANGELOG.md` | Fixed entry |+| `prism/Localizable.xcstrings` | Four keys used only by the deleted view removed; "Metadata" is kept and reused |+| `docs/agent-notes/scroll-persistence.md` | Picker section rewritten: it described the removed rendered-ness filter |+| `docs/accessibility-review.md` | Pointer to the deleted view annotated |++## Verification++**Automated:**+- [x] Regression test passes+- [x] Targeted suites pass — 64 tests across the emitter, parity, and three live-WebKit suites, verified through `Tools/check-test-results.sh`. One run of the four-locale plan failed `activatingAnotherSessionPersistsOutgoingPosition` once; that test does not go through the changed code, passed on the other three configurations, and passed 21 of 21 when the suite was re-run alone. See Related.+- [x] `make build-ios` and `make build-macos` pass with zero warnings+- [x] `make lint` (SwiftLint, 0 violations) and `make lint-css` (stylelint, clean)+- [ ] Full `make test` suite — not run for this change; the targeted suites above cover every file touched++**Manual verification:**+- Installed on an iPhone via `make install` and confirmed by the user: the collapsed "Metadata" card appears above the first heading of a frontmatter document and expands in place on tap.++## Prevention++**Recommendations to avoid similar bugs:**+- When a renderer migration emits a placeholder for a block kind, file a ticket for the placeholder in the same change and reference it from the emitter comment. "Not rendered today" with no ticket reads as a design decision after the next three tickets harden around it.+- The parity fixture suite excludes `[data-prism-metadata]` from the text comparison on both sides. That exclusion was correct for a collapsed card and remains so, but it also meant parity could not detect the card's absence. A structural-marker check (now in `WebParityFixtureTests`) is the right place to pin a block kind's presence when its text is deliberately out of scope for parity.+- `MarkdownBlock` has one case per block kind and `BlockHTMLEmitter.emit` is total over them; a test that asserts every case produces visible output unless explicitly listed as body-less (`noContentDropped`) is now one case stricter, since `.metadata` left that list.++## Related++- T-1542 — WebKit rendering cutover, where the card was dropped.+- T-1851, T-1944, T-1701 — the three fixes that hardened the scroll-position machinery around the hidden carrier; each is referenced in the rewritten tests.+- `ScrollPositionStore.save` does an unlocked load, modify, save on shared `UserDefaults`, and the test plan runs four locale configurations as concurrent host processes against the same domain. That lost-update race is the likely cause of the one spurious failure seen during verification and is worth its own ticket; it is untouched here.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 729a80ce..b8c80bbb 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A document's YAML frontmatter is shown again as a collapsed "Metadata" card at the top of the document, expandable in place to reveal the raw YAML. The WebKit rendering cutover (T-1542) dropped this: the parser still produced the frontmatter block, but the emitter wrote it out as an inert hidden section, so a document that opened with `---` metadata simply started at its first heading with no way to see what the frontmatter said. The card is a native `<details>` disclosure — no script of its own — whose title comes from the localisation catalog, whose body is the YAML as escaped monospaced text, and whose open state is recorded natively like every other disclosure, so it survives a WebContent recovery. Because the block now lays out, the native raw-to-rendered position picker no longer has to skip it to agree with the page about which sections exist, and the pre-cutover SwiftUI `MetadataView` that nothing had rendered since the cutover is removed.+ - A note anchored to a table's header row now draws its own indicator dot on the header row, instead of none at all (T-2044). T-1725 had removed the header row's indicator deliberately rather than leave it wrong: with no ordinal to address it by, a header-row note used to fold into the table's block-level dot, which opens a popover scoped to the block and so could never contain the header row's own note — and once that dot started announcing its count out loud, it was announcing a note the tap could not show. The header row now gets an indicator of its own, addressed by `subID: "row-header"` — the same channel T-1745 opened for list items — rather than a second one; `WebDocumentMessageRouter` resolves that exact string back to the header row's anchor, and `prism-notes.js` places the dot inside the header row's own cell, since a `<tr>` may only contain `<td>`/`<th>` and the list-item placement path it reuses cannot be used verbatim. Header-row notes were always visible through the inline note bubble and the notes panel; only the row's own indicator dot was missing. This is the READING half only: a header row still cannot be given a note by long-press or right-click (that gesture falls back to a note on the whole table), so a header-row note continues to arrive by import or through the text-selection path. That is T-2318. - A local link or image with a percent-encoded absolute path now opens the file it actually names, instead of a file literally named with the percent escapes still in it (T-2066). `LinkPathResolver` and `ImagePathResolver` split query/fragment off a root-absolute `/…` destination before handing the remaining path straight to `URL(fileURLWithPath:)` — which does no decoding of its own — so `[Doc](/Users/me/My%20Doc.md)` or `` resolved to a nonexistent file named `My%20Doc.md`/`My%20Image.png` rather than the one with a space in its name. Both root-absolute branches now decode the split path first, matching the plain-relative branch a few lines below, which already did. The split itself still runs on the raw, undecoded source, so a percent-encoded `%23`/`%3F` inside the path is never mistaken for a live fragment/query delimiter after decoding. An encoded `%2F` is decoded too, consistent with that same plain-relative branch: a real filesystem entry can never contain a literal `/` in its own name, so — unlike the remote `.url` branches, where an escaped slash must stay escaped because it changes which server resource is requested (T-1850/T-2140) — there is no reading of a local path where leaving it encoded names a real file.
Open the card, force a WebContent termination (or trigger the recovery path in a test), and confirm the card comes back open. The path is the same as an authored <details> and was traced, not exercised.
Confirmed on device by the user that the card expands on tap. The summary is a native disclosure so VoiceOver announces it as expandable; worth a quick VoiceOver pass since no UI test covers it.
Independent of this branch, but now observed: ScrollPositionStore.save is an unlocked load-modify-save on shared UserDefaults and the test plan runs four locale hosts concurrently. File a ticket.