T-2190 sharesheet polish: one title per sheet, an identified edit sheet, and the rating where the reader will see it. Eight commits including two review-fix rounds.
The capture sheet hides the raw page title whenever a parsed title exists (CaptureOutcome.hasParsedTitle, six-row truth table), drops its hostname row entirely, and omits the whole metadata card when nothing fills it.
The parsed card lists Work above Chapter; the rating toggles sit above the note on both sheets so the keyboard can't hide them.
The re-share edit sheet now names the entry being annotated: carrier.chapterTitle ?? representative.captureTitle, sourcing-parity with snapshot(_:), pinned by a mutation-verified split-group test.
Follow-ups filed rather than scope-crept: T-2193 (pre-existing VoiceOver label bug), T-2194 (articles cleaned-title divergence on the edit sheet).
Ready to push
Four parallel review agents (reuse, quality, efficiency, spec adherence) raised nine findings, all minor or lower — six fixed in a27c535, three deliberately skipped with reasons recorded. The full AsterismCore suite passes (1,503 tests), the app and extension build clean, and a forced recompile of every changed production file shows zero new warnings. The carrier-vs-representative title sourcing — the one real design decision — is mutation-verified by test.
b89cb8c T-2190: Sharesheet polish smolspec 3dd36c1 T-2190: Incorporate design-critic findings into smolspec 22431b8 T-2190: Sharesheet polish — one title per sheet, rating above the note ec9ff50 T-2190: Correct review-flagged rationale in spec docs e6341e3 T-2190: Apply review fixes to the re-share edit basis and its tests ea803b3 T-2190: Changelog and specs overview for sharesheet polish 538371a T-2190: Drop the hostname row, list work above chapter a27c535 T-2190: Pre-push review fixes Asterism's share sheet — the small screen that appears when you share a web page into the app — got four cleanups. First, it used to show the page title twice: once raw, as the browser gave it, and once parsed into "Work" and "Chapter" rows. When the app has parsed the title, the raw copy is now hidden; when it couldn't parse it (an unknown site, or an unexpected title format), the raw title still shows because it's the only one available.
Second, the row showing the website's name is gone — you're on that page when you share it, so it told you nothing. If nothing else fills that card, the whole card disappears rather than showing an empty box. Third, the parsed card now names the work (the story) before the chapter, like a book title before a page number.
Fourth, the edit sheet — shown when you re-share a page you already saved — now displays the entry's title, so you can tell what you're editing. And on both sheets the rating buttons moved above the note field, because the keyboard that pops up for the note used to hide them.
Four production files. CaptureView.metadataSection gains an outcome: CaptureOutcome? parameter from both callers (ready and save-failed) and skips the raw-title Text when outcome?.hasParsedTitle == true. The hostname row and its hostname(from:) helper are deleted (Q12), and the card is guarded to render only when a new-site banner, the manual-title input, or the raw title fills it. The hiding condition lives in Core as CaptureOutcome.hasParsedTitle (projectedChapter != nil || displayTitle != nil, Q1/Q6) because the extension views have no automated UI coverage — a Core property gets a truth-table test, a view conditional gets nothing. Parse failure leaves both fields nil, which is what keeps untaught and unmatched titles visible for free.
The re-share edit sheet gains the entry title: ReShareEditBasis.title, populated as carrier.chapterTitle ?? representative.captureTitle — reproducing snapshot(_:)'s split-group sourcing (Q3) — plumbed through ReShareEditState and rendered above the edit banner as reshare.title, omitted when empty (Q7; the field is non-optional String, so no new nil state, at the cost of six memberwise-init test fix-ups). The stale rebuild takes the refreshed basis title; the failure rebuild preserves the current state by mutating a copy (Q14).
hasParsedTitle is a projection predicate, not a site-state predicate: it collapses four site states into three observable shapes, and the truth table says so honestly — the both-set row is flagged unreachable (articles nils projectedChapter, taught never sets displayTitle) and pins the || itself; the two nil/nil rows are field-identical and named indistinguishable. ArticleTitleCleaner.clean is total, so every articles capture hides the raw row even on a no-op clean (Q9) — correct, since the Article row would repeat it verbatim. The known hole is Q11: an untrimmed whole-title rule derives a work title equal to the page title while setting neither predicate input. Widening to string comparison would mis-handle the trimmed case, where hiding the raw title loses the trimmed-away text.
The carrier/representative sourcing is the part worth reviewing. The first-cut rationale claimed chapter titles are authored content and edit-sheet parity with Entry detail; review corrected both — a pattern-derived chapterTitle is a derived field (Q44, duplicate-reconciliation) that neither selects the carrier nor tears a group, and Entry detail's heading additionally falls through chapterSequence and the cleaned articles displayTitle, which reShareBasis does not carry (hence T-2194). The claim is now sourcing parity with snapshot(_:) only. The load-bearing test seeds two same-identity rows with different pattern-derived chapter titles (legal precisely because derived fields don't tear), a bare representative that merely sorts first, and a note-selected carrier — mutation-verified: flipping carrier to representative fails it and nothing else. M5SeedEntry.chapterTitleProvenance exists solely to make that shape seedable.
refreshedBasis — the title can change under the user mid-edit, matching the entry they're about to overwrite."".capture.hostname removed (no test referenced it); capture.metadata is now conditional. T-2193 (VoiceOver label on the page title) is pre-existing, filed from review, untouched here.ProjectionContract.swift
Why it matters. This is the whole hiding rule — projectedChapter != nil || displayTitle != nil. Parse failure leaves both nil, so untaught and unmatched titles keep the raw row with no extra flag.
What to look at. CaptureOutcome.hasParsedTitle, ProjectionContract.swift:346-350
CaptureView.swift
Why it matters. The card now renders only when the new-site banner, manual-title input, or raw title fills it — removing the hostname row without this guard would have left an empty styled box whenever the parsed title hides the raw one. The manual-title input is never suppressed, so the hiding can't swallow the user's own typing.
What to look at. metadataSection(preparation:outcome:), CaptureView.swift:177-208
LibraryRepository+Capture.swift
Why it matters. For split duplicate groups the chapter title reads off the carrier (the row whose authored content the group presents), the raw capture title off the representative — sourcing parity with snapshot(_:) only, not Entry detail's full heading chain.
What to look at. ReShareEditBasis.title; EntryGroup.reShareBasis(hostname:), LibraryRepository+Capture.swift:316-334
LookupCaptureViewModel.swift
Why it matters. The save-failure rebuild mutates a copy of the current state (nothing changed on disk, so the title stands); the stale rebuild constructs from the refreshed basis (a concurrent writer may have retitled the entry). Three ReShareExtensionUITests pin exactly this.
What to look at. ReShareEditState.title; submitUpdate failure and stale paths
RepositoryReShareTests.swift
Why it matters. The only test that can distinguish carrier from representative sourcing — two rows, same identity, different pattern-derived chapter titles (legal because derived fields don't tear a group, Q44), carrier chosen by its note. Verified discriminating: flipping the source fails it and nothing else.
What to look at. editBasisReportsTheCarriersChapterTitle; M5SeedEntry.chapterTitleProvenance in M5RepositoryTestSupport.swift
CaptureView.swift
Why it matters. Work names what the capture belongs to, chapter locates it within; the parse-failure row rides with the work block so it now precedes the no-chapter row. Rating sits above the note so the keyboard raised for the note can't hide it — the ticket's original complaint.
What to look at. projectedMetadataSection block reorder; ratingSection/noteSection swaps in readyContent, failedContent, editContent
The extension views have no automated coverage, so a Core computed property gets a truth-table test where a view conditional would get nothing (Q6). The predicate is projectedChapter != nil || displayTitle != nil (Q1) — the two texts the projected card renders as a title.
ArticleTitleCleaner.clean is total and the articles path always sets displayTitle (Q9). Hiding the raw row is still a dedup — the Article row would repeat it verbatim. The untrimmed whole-title rule remains a known double-show (Q11): widening the predicate to string comparison would lose information for trimmed rules.
Reproduces snapshot(_:)'s split-group sourcing (Q3). Sourcing parity only — Entry detail's heading also falls through chapterSequence and the cleaned articles title, context reShareBasis does not hold; that divergence is T-2194. A first-cut claim that chapter titles are authored content was corrected: a pattern-derived title is a derived field (Q44 in duplicate-reconciliation) and does not select the carrier.
Device-check feedback (Q12): the sharer is on the page they are sharing, so the site row adds nothing. Overrules Q2's hostname half; the manual-input half stands. CaptureView's private hostname(from:) and the extension's only SiteGlyph usage were deleted — the component itself lives on in the main app.
Q13 (device check): the work names what the capture belongs to, the chapter locates it within. Q4: both sheets share the layout, so the rating/note swap applies to capture and edit alike.
Q14: a save failure changes nothing on disk, so the title the reader saw stands (the failure path mutates a copy of the current state); a stale result means a concurrent writer changed the entry, so the refreshed basis's title matches what the reader is about to overwrite. Both paths pinned in ReShareExtensionUITests.
captureTitle defaults to "", so a title-less capture yields an empty basis title the sheet omits (Q7) — now pinned by test. Leaving the memberwise-init parameter default-less forced all six test construction sites through the compiler instead of silently inheriting "".
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | LookupCaptureViewModel failure path | Save-failure rebuilt ReShareEditState memberwise (nine fields) when only cursorAtEnd and errorMessage change; the file's own idiom is mutate-a-copy, and every new field paid a three-site tax. | Rewritten as a mutable copy; behaviour identical, saveFailureKeepsTitle still passes. |
| minor | specs decision log Q12 | Q12 claimed SiteGlyph itself was deleted; only the extension's usage was — the component lives on with seven main-app callers. | Reworded to name exactly what was deleted. |
| minor | specs tasks.md | Blocked-by echo lines were corrupted: rune parsed commas inside quoted task titles as extra dependency tokens and re-emitted garbage fragments. | Replaced with short comma-safe echoes; rune list parses cleanly. |
| minor | RepositoryReShareTests coverage | Q7's premise — a title-less capture yields an empty basis title — had no test; all three title cases used non-empty titles. | Added editBasisReportsAnEmptyTitleForATitleLessCapture. |
| nit | CaptureView metadataSection | showsRawTitle double-encoded the nil check: the guard tested resolvedTitle != nil and the render branch re-unwrapped both halves. | Bound once as let rawTitle, gated on rawTitle != nil. |
| nit | specs tasks.md task 1 | Task 1's text still described the hostname as staying visible — historical, but ambiguous against task 6. | Appended "(hostname later removed — task 6, Q12)". |
| minor | reShareBasis title sourcing | carrier.chapterTitle ?? representative.captureTitle restates snapshot(_:)'s sourcing rather than calling it. | Deliberate and documented: reusing snapshot would make reShareBasis throwing at three call sites and build a full EntrySnapshot for two field reads. The parity is stated in the comment and pinned by test. |
| minor | CaptureView ready/failed layouts | The four-section sequence is duplicated across readyContent and failedContent; this diff had to edit both, and the section order is now policy in two places. | Pre-existing duplication; extracting a shared captureSections builder is a reasonable follow-up but touching the view structure again post-device-check wasn't worth it here. |
| nit | M5RepositoryTestSupport seed field | chapterTitleProvenance uses an optional with nil-means-manual where the sibling field uses a non-optional default. | Cosmetic, test-support only; the optional keeps every existing suite's seeding untouched. |
Click to expand.
diff --git a/Asterism/AsterismShareExtension/CaptureView.swift b/Asterism/AsterismShareExtension/CaptureView.swiftindex 5e2d1f8..366b3e2 100644--- a/Asterism/AsterismShareExtension/CaptureView.swift+++ b/Asterism/AsterismShareExtension/CaptureView.swift@@ -122,15 +122,15 @@ struct CaptureView: View { @ViewBuilder private func readyContent(preparation: CapturePreparation, outcome: CaptureOutcome?, reviewMessage: String?) -> some View {- metadataSection(preparation: preparation)+ metadataSection(preparation: preparation, outcome: outcome) if let outcome { projectedMetadataSection(outcome: outcome) } if let reviewMessage { reviewBanner(message: reviewMessage) }- noteSection ratingSection+ noteSection } // MARK: - Saving@@ -151,12 +151,12 @@ struct CaptureView: View { @ViewBuilder private func failedContent(preparation: CapturePreparation, outcome: CaptureOutcome?, message: String) -> some View { errorBanner(message: message)- metadataSection(preparation: preparation)+ metadataSection(preparation: preparation, outcome: outcome) if let outcome { projectedMetadataSection(outcome: outcome) }- noteSection ratingSection+ noteSection } // MARK: - Saved@@ -174,46 +174,38 @@ struct CaptureView: View { .accessibilityLabel("Capture saved successfully") } - // MARK: - Metadata (title + site)+ // MARK: - Metadata (title) + /// The raw page title is the projected card's title again whenever the+ /// projection parsed one, so it is shown only when the projection has none+ /// (Q1). The manual-title input is an editor rather than an echo, so it+ /// stays regardless (Q2). No hostname row: the sharer is on the page they+ /// are sharing (Q12). With nothing to show, the card itself is omitted+ /// rather than rendering an empty field. @ViewBuilder- private func metadataSection(preparation: CapturePreparation) -> some View {- VStack(alignment: .leading, spacing: 8) {- if preparation.siteStatus == .newSite {- newSiteBanner- }-- if preparation.titleSource == .manual && preparation.resolvedTitle == nil {- manualTitleInput- } else if let title = preparation.resolvedTitle {- Text(title)- .font(AsterismTypography.serifHeading)- .accessibilityLabel("Page title")- .accessibilityIdentifier("capture.pageTitle")- }+ private func metadataSection(preparation: CapturePreparation, outcome: CaptureOutcome?) -> some View {+ let showsManualInput = preparation.titleSource == .manual && preparation.resolvedTitle == nil+ let rawTitle = outcome?.hasParsedTitle == true ? nil : preparation.resolvedTitle+ if preparation.siteStatus == .newSite || showsManualInput || rawTitle != nil {+ VStack(alignment: .leading, spacing: 8) {+ if preparation.siteStatus == .newSite {+ newSiteBanner+ } - if let host = Self.hostname(from: preparation.rawURL) {- HStack(spacing: 6) {- // Requirement 9.2: a hostname shown as plain text takes the- // glyph instead.- SiteGlyph(- hostname: host,- kind: preparation.siteStatus == .newSite ? .unknown : .site,- size: 18- )- Text(host)- .font(.caption)- .foregroundStyle(AsterismColors.secondaryText)+ if showsManualInput {+ manualTitleInput+ } else if let rawTitle {+ Text(rawTitle)+ .font(AsterismTypography.serifHeading)+ .accessibilityLabel("Page title")+ .accessibilityIdentifier("capture.pageTitle") }- .accessibilityElement(children: .combine)- .accessibilityLabel("Site: \(host)")- .accessibilityIdentifier("capture.hostname") }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding()+ .constellationField()+ .accessibilityIdentifier("capture.metadata") }- .frame(maxWidth: .infinity, alignment: .leading)- .padding()- .constellationField()- .accessibilityIdentifier("capture.metadata") } // MARK: - Projected Metadata Section@@ -235,35 +227,8 @@ struct CaptureView: View { .accessibilityIdentifier("capture.articleTitle") } - // Chapter / parse information- if let chapter = outcome.projectedChapter {- HStack(spacing: 6) {- Image(systemName: "bookmark.fill")- .font(.caption)- .foregroundStyle(AsterismColors.cyan)- .accessibilityHidden(true)- Text("Chapter: \(chapter)")- .font(.subheadline)- }- .accessibilityElement(children: .combine)- .accessibilityLabel("Chapter: \(chapter)")- .accessibilityIdentifier("capture.projectedChapter")- } else {- HStack(spacing: 6) {- Image(systemName: "questionmark.circle")- .font(.caption)- .foregroundStyle(.secondary)- .accessibilityHidden(true)- Text(outcome.intentionallyUnattached ? "Independent article" : "No chapter parsed from title")- .font(.subheadline)- .foregroundStyle(.secondary)- }- .accessibilityElement(children: .combine)- .accessibilityLabel(outcome.intentionallyUnattached ? "Independent article" : "No chapter parsed from title")- .accessibilityIdentifier("capture.noChapter")- }-- // Work information+ // Work information — above the chapter: the work names what the+ // capture belongs to, the chapter locates it within (Q13). if let workTitle = outcome.projectedWorkTitle { let workDescription: String = { switch outcome.workOutcome {@@ -304,6 +269,34 @@ struct CaptureView: View { .accessibilityIdentifier("capture.parseFailure") } + // Chapter / parse information+ if let chapter = outcome.projectedChapter {+ HStack(spacing: 6) {+ Image(systemName: "bookmark.fill")+ .font(.caption)+ .foregroundStyle(AsterismColors.cyan)+ .accessibilityHidden(true)+ Text("Chapter: \(chapter)")+ .font(.subheadline)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel("Chapter: \(chapter)")+ .accessibilityIdentifier("capture.projectedChapter")+ } else {+ HStack(spacing: 6) {+ Image(systemName: "questionmark.circle")+ .font(.caption)+ .foregroundStyle(.secondary)+ .accessibilityHidden(true)+ Text(outcome.intentionallyUnattached ? "Independent article" : "No chapter parsed from title")+ .font(.subheadline)+ .foregroundStyle(.secondary)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel(outcome.intentionallyUnattached ? "Independent article" : "No chapter parsed from title")+ .accessibilityIdentifier("capture.noChapter")+ }+ // Actionable state indicator if outcome.actionable { HStack(spacing: 6) {@@ -464,11 +457,6 @@ struct CaptureView: View { .accessibilityIdentifier("capture.error") } - // MARK: - Helpers-- private static func hostname(from urlString: String) -> String? {- URLComponents(string: urlString)?.host- } } // MARK: - Observable wrapper for CaptureViewModel (bridges @MainActor to SwiftUI)
diff --git a/Asterism/AsterismShareExtension/ReShareCaptureView.swift b/Asterism/AsterismShareExtension/ReShareCaptureView.swiftindex 09253a2..2df7287 100644--- a/Asterism/AsterismShareExtension/ReShareCaptureView.swift+++ b/Asterism/AsterismShareExtension/ReShareCaptureView.swift@@ -114,6 +114,16 @@ struct ReShareCaptureView: View { @ViewBuilder private func editContent(state: ReShareEditState) -> some View {+ // Which entry is being annotated. A title-less capture renders nothing+ // rather than an empty row (Q7).+ if !state.title.isEmpty {+ Text(state.title)+ .font(AsterismTypography.serifHeading)+ .frame(maxWidth: .infinity, alignment: .leading)+ .accessibilityLabel("Entry title: \(state.title)")+ .accessibilityIdentifier("reshare.title")+ }+ // Banner: "Noted <date> — editing existing entry" editBanner(firstCapturedAt: state.firstCapturedAt) @@ -122,11 +132,11 @@ struct ReShareCaptureView: View { errorBanner(message: errorMessage) } - // Note section (prefilled, cursor at end)- noteSection- // Rating section ratingSection++ // Note section (prefilled, cursor at end)+ noteSection } private func editBanner(firstCapturedAt: Date) -> some View {
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swiftindex 8c7ed33..837ea69 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift@@ -343,6 +343,12 @@ public struct CaptureOutcome: Sendable, Equatable { /// The composed Work assignment, or nil when the Site applies no title rule. public let composedAssignment: ComposedAssignmentProjection? + /// Whether the projection carries a title of its own — the projected chapter+ /// or the articles display title. A parse failure leaves both nil (Q1).+ public var hasParsedTitle: Bool {+ projectedChapter != nil || displayTitle != nil+ }+ public init(projectedChapter: String?, projectedWorkTitle: String?, workOutcome: WorkMatchOutcome?, projectedWorkID: UUID?, actionable: Bool, intentionallyUnattached: Bool,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swiftindex 3d852dd..8c0682b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift@@ -44,6 +44,9 @@ public struct ReShareEditBasis: Sendable, Equatable { public let hostname: String /// The identity key that produced the match. public let identityKey: String+ /// The Entry's title for the edit sheet: the parsed chapter title when one+ /// was stored, else the raw capture title. Empty for a title-less capture.+ public let title: String /// Persisted note at lookup time. public let persistedNote: String /// Persisted rating at lookup time.@@ -57,6 +60,7 @@ public struct ReShareEditBasis: Sendable, Equatable { entryID: UUID, hostname: String, identityKey: String,+ title: String, persistedNote: String, persistedRating: Rating?, persistedModifiedAt: Date,@@ -65,6 +69,7 @@ public struct ReShareEditBasis: Sendable, Equatable { self.entryID = entryID self.hostname = hostname self.identityKey = identityKey+ self.title = title self.persistedNote = persistedNote self.persistedRating = persistedRating self.persistedModifiedAt = persistedModifiedAt@@ -308,12 +313,21 @@ extension EntryGroup { /// The re-share basis this logical record presents: the group's authored /// content (Q41), its member timestamps (Definitions), and the /// representative's identity key as evidence.+ ///+ /// The title reproduces `snapshot(_:)`'s split-group sourcing (Q3): the+ /// chapter title off the carrier, because that is the row whose content the+ /// group presents, and the raw capture title as evidence from the+ /// representative. A *parsed* chapter title is a derived field (Q44) and+ /// takes no part in selecting the carrier. Sourcing parity only — Entry+ /// detail's heading falls back further, through `chapterSequence` and the+ /// cleaned articles title, which this basis does not carry. func reShareBasis(hostname: String) -> ReShareEditBasis { let content = presentedContent return ReShareEditBasis( entryID: id, hostname: hostname, identityKey: representative.entryIdentityKey,+ title: carrier.chapterTitle ?? representative.captureTitle, persistedNote: content.note, persistedRating: content.rating, persistedModifiedAt: modifiedAt,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift b/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swiftindex 8ef1785..2b1283b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift@@ -20,6 +20,9 @@ public enum LookupCaptureState: Sendable, Equatable { /// State exposed when editing an existing Entry on re-share. public struct ReShareEditState: Sendable, Equatable { public let entryID: UUID+ /// The entry's title, so the reader can tell which entry they are+ /// annotating. Empty for a title-less capture, which the sheet omits.+ public let title: String public let persistedNote: String public let persistedRating: Rating? public let firstCapturedAt: Date@@ -30,6 +33,7 @@ public struct ReShareEditState: Sendable, Equatable { public init( entryID: UUID,+ title: String, persistedNote: String, persistedRating: Rating?, firstCapturedAt: Date,@@ -39,6 +43,7 @@ public struct ReShareEditState: Sendable, Equatable { errorMessage: String? = nil ) { self.entryID = entryID+ self.title = title self.persistedNote = persistedNote self.persistedRating = persistedRating self.firstCapturedAt = firstCapturedAt@@ -158,6 +163,7 @@ public final class LookupCaptureViewModel { currentEditBasis = basis lookupState = .readyEdit(ReShareEditState( entryID: basis.entryID,+ title: basis.title, persistedNote: basis.persistedNote, persistedRating: basis.persistedRating, firstCapturedAt: basis.firstCapturedAt,@@ -219,16 +225,10 @@ public final class LookupCaptureViewModel { } catch { // Req 4.11: retain draft, explain failure, allow retry lookupLogger.error("Re-share update failed: \(String(describing: error), privacy: .public)")- lookupState = .readyEdit(ReShareEditState(- entryID: editState.entryID,- persistedNote: editState.persistedNote,- persistedRating: editState.persistedRating,- firstCapturedAt: editState.firstCapturedAt,- draftNote: draftNote,- draftRating: draftRating,- cursorAtEnd: false,- errorMessage: "Unable to save. Please try again."- ))+ var failedState = editState+ failedState.cursorAtEnd = false+ failedState.errorMessage = "Unable to save. Please try again."+ lookupState = .readyEdit(failedState) return } @@ -243,6 +243,7 @@ public final class LookupCaptureViewModel { currentEditBasis = refreshedBasis lookupState = .readyEdit(ReShareEditState( entryID: refreshedBasis.entryID,+ title: refreshedBasis.title, persistedNote: refreshedBasis.persistedNote, persistedRating: refreshedBasis.persistedRating, firstCapturedAt: refreshedBasis.firstCapturedAt,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swiftindex db8c46a..eae4a67 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift@@ -387,6 +387,83 @@ struct CaptureStateTests { return } }++ // MARK: - hasParsedTitle (T-2190: the capture sheet's raw-title suppression)++ @Test("hasParsedTitle is true exactly when the projected card shows a title",+ arguments: ParsedTitleCase.truthTable)+ fileprivate func hasParsedTitleTruthTable(cell: ParsedTitleCase) {+ let outcome = CaptureOutcome(+ projectedChapter: cell.projectedChapter,+ projectedWorkTitle: cell.projectedChapter == nil ? nil : "Test Work",+ workOutcome: nil,+ projectedWorkID: nil,+ actionable: cell.projectedChapter == nil,+ intentionallyUnattached: false,+ displayTitle: cell.displayTitle+ )+ #expect(outcome.hasParsedTitle == cell.hidesRawTitle,+ "\(cell.name): raw page title \"\(cell.rawPageTitle)\"")+ }+}++/// One row of the `hasParsedTitle` truth table. `rawPageTitle` is the title the+/// capture sheet would otherwise print; it is what the cases are reasoning+/// about even though the property only reads the projection.+private struct ParsedTitleCase: Sendable {+ let name: String+ let rawPageTitle: String+ let projectedChapter: String?+ let displayTitle: String?+ let hidesRawTitle: Bool++ static let truthTable: [ParsedTitleCase] = [+ ParsedTitleCase(+ name: "taught site, parse succeeded",+ rawPageTitle: "Some Story - Chapter 5",+ projectedChapter: "5",+ displayTitle: nil,+ hidesRawTitle: true),+ ParsedTitleCase(+ name: "articles site, suffix cleaned away",+ rawPageTitle: "A Long Read | The Paper",+ projectedChapter: nil,+ displayTitle: "A Long Read",+ hidesRawTitle: true),+ // Q9: the articles path always sets a display title, so a clean that+ // changed nothing still hides the raw row — the Article row would+ // otherwise repeat it verbatim.+ ParsedTitleCase(+ name: "articles site, clean was a no-op",+ rawPageTitle: "A Long Read",+ projectedChapter: nil,+ displayTitle: "A Long Read",+ hidesRawTitle: true),+ // No outcome reaches this shape: the articles branch nils+ // `projectedChapter`, the taught branch never sets `displayTitle`, and a+ // Site has one mode. The row pins the `||` itself, not a site state.+ ParsedTitleCase(+ name: "both projected fields set (unreachable; pins the ||)",+ rawPageTitle: "A Long Read - Chapter 5 | The Paper",+ projectedChapter: "5",+ displayTitle: "A Long Read",+ hidesRawTitle: true),+ // The last two rows are field-identical on purpose: the property reads+ // the projection only, so it cannot tell these two site states apart.+ ParsedTitleCase(+ name: "neither field set — untaught site (indistinguishable here)",+ rawPageTitle: "Some Story - Chapter 5",+ projectedChapter: nil,+ displayTitle: nil,+ hidesRawTitle: false),+ ParsedTitleCase(+ name: "neither field set — taught site, title misses the pattern "+ + "(indistinguishable here)",+ rawPageTitle: "About this site",+ projectedChapter: nil,+ displayTitle: nil,+ hidesRawTitle: false)+ ] } // MARK: - Fake CaptureCoordinating for controllable testing
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryReShareTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryReShareTests.swiftindex a0a06d5..9ee1124 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryReShareTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryReShareTests.swift@@ -530,6 +530,105 @@ struct RepositoryReShareTests { } #expect(basis.persistedModifiedAt == MillisecondInstant.quantize(captureTime)) }++ // MARK: - Edit basis title for the edit sheet (T-2190)++ @Test("Edit basis reports the stored chapter title")+ func editBasisReportsTheChapterTitle() async throws {+ let fixture = try await M5Fixture()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "chapter.example")],+ entries: [+ M5SeedEntry(+ id: UUID(), captureTitle: "Some Story - Chapter 14",+ hostname: "chapter.example", path: "14", chapterTitle: "Chapter 14")+ ])++ let result = try await fixture.repository.captureLookup(+ rawURL: "https://chapter.example/14")+ guard case .edit(let basis) = result else {+ Issue.record("Expected edit disposition, got \(result)")+ return+ }+ #expect(basis.title == "Chapter 14")+ }++ /// A split group's title comes off the **carrier**, the row whose content+ /// the group presents — not off the representative, which here is a bare+ /// copy that merely sorts first (the representative order leads on+ /// `captureTitle`).+ ///+ /// Both chapter titles are pattern-derived, so they may differ without+ /// tearing the group: a derived title is not authored content (Q44). That+ /// matters — a *torn* group dispatches to `.new` and never reaches an edit+ /// basis at all, so tornness is not a shape this assertion could use.+ @Test("Edit basis reports the carrier's chapter title, not the representative's")+ func editBasisReportsTheCarriersChapterTitle() async throws {+ let fixture = try await M5Fixture()+ let entryID = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "split.example")],+ entries: [+ // Representative: sorts first, carries nothing the reader wrote.+ M5SeedEntry(+ id: entryID, captureTitle: "A copy - Chapter 13",+ hostname: "split.example", path: "42",+ chapterTitle: "Chapter 13", chapterTitleProvenance: .pattern),+ // Carrier: the note is the group's presented content.+ M5SeedEntry(+ id: entryID, captureTitle: "B copy - Chapter 14",+ hostname: "split.example", path: "42", note: "the note",+ chapterTitle: "Chapter 14", chapterTitleProvenance: .pattern)+ ])++ let result = try await fixture.repository.captureLookup(+ rawURL: "https://split.example/42")+ guard case .edit(let basis) = result else {+ Issue.record("Expected edit disposition, got \(result)")+ return+ }+ #expect(basis.persistedNote == "the note")+ #expect(basis.title == "Chapter 14")+ }++ @Test("Edit basis falls back to the raw capture title when no chapter was parsed")+ func editBasisFallsBackToTheCaptureTitle() async throws {+ let fixture = try await ReShareFixture()+ _ = try await fixture.repository.capture(+ .reShare(rawURL: "https://example.com/chapter-1")+ )++ let result = try await fixture.repository.captureLookup(+ rawURL: "https://example.com/chapter-1"+ )+ guard case .edit(let basis) = result else {+ Issue.record("Expected edit disposition, got \(result)")+ return+ }+ #expect(basis.title == "Test Chapter")+ }++ /// A title-less capture yields the empty string, the value the edit sheet+ /// omits rather than rendering (Q7).+ @Test("Edit basis reports an empty title for a title-less capture")+ func editBasisReportsAnEmptyTitleForATitleLessCapture() async throws {+ let fixture = try await M5Fixture()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "bare.example")],+ entries: [+ M5SeedEntry(+ id: UUID(), captureTitle: "",+ hostname: "bare.example", path: "1")+ ])++ let result = try await fixture.repository.captureLookup(+ rawURL: "https://bare.example/1")+ guard case .edit(let basis) = result else {+ Issue.record("Expected edit disposition, got \(result)")+ return+ }+ #expect(basis.title.isEmpty)+ } } // MARK: - Test Infrastructure
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swiftindex 16e75d3..612a35b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift@@ -136,6 +136,7 @@ struct ReShareExtensionUITests { entryID: fixture.entryID, hostname: "example.com", identityKey: "https://example.com/chapter-1",+ title: "Chapter 1", persistedNote: "concurrent note", persistedRating: nil, persistedModifiedAt: Date(timeIntervalSince1970: 1_721_500_000),@@ -172,6 +173,65 @@ struct ReShareExtensionUITests { #expect(state.errorMessage!.contains("try again")) } + // MARK: - Entry title on the edit sheet (T-2190)++ @Test("Edit state carries the basis title after lookup")+ @MainActor func editStateCarriesTitle() async throws {+ let fixture = ReShareUIFixture.editExisting()+ await fixture.viewModel.loadWithLookup(+ payload: fixture.payload,+ coordinator: fixture.coordinator+ )+ guard case .readyEdit(let state) = fixture.viewModel.lookupState else {+ Issue.record("Expected readyEdit")+ return+ }+ #expect(state.title == "Chapter 1")+ }++ @Test("Stale rebuild takes the refreshed basis title")+ @MainActor func staleRebuildTakesRefreshedTitle() async throws {+ let fixture = ReShareUIFixture.editExisting()+ await fixture.viewModel.loadWithLookup(+ payload: fixture.payload,+ coordinator: fixture.coordinator+ )+ fixture.coordinator.reShareOutcome = .stale(ReShareEditBasis(+ entryID: fixture.entryID,+ hostname: "example.com",+ identityKey: "https://example.com/chapter-1",+ title: "Chapter 1 (retitled)",+ persistedNote: "concurrent note",+ persistedRating: nil,+ persistedModifiedAt: Date(timeIntervalSince1970: 1_721_500_000),+ firstCapturedAt: Date(timeIntervalSince1970: 1_720_000_000)+ ))+ await fixture.viewModel.submitUpdate(coordinator: fixture.coordinator)++ guard case .readyEdit(let state) = fixture.viewModel.lookupState else {+ Issue.record("Expected readyEdit after stale")+ return+ }+ #expect(state.title == "Chapter 1 (retitled)")+ }++ @Test("Save failure keeps the title on the rebuilt edit state")+ @MainActor func saveFailureKeepsTitle() async throws {+ let fixture = ReShareUIFixture.editExisting()+ await fixture.viewModel.loadWithLookup(+ payload: fixture.payload,+ coordinator: fixture.coordinator+ )+ fixture.coordinator.reShareShouldThrow = true+ await fixture.viewModel.submitUpdate(coordinator: fixture.coordinator)++ guard case .readyEdit(let state) = fixture.viewModel.lookupState else {+ Issue.record("Expected readyEdit with error")+ return+ }+ #expect(state.title == "Chapter 1")+ }+ // MARK: - Manual-open dismissal (extension without readiness) @Test("Manual-open instruction uses correct message and allows dismissal only")@@ -217,6 +277,7 @@ private struct ReShareUIFixture { entryID: entryID, hostname: "example.com", identityKey: "https://example.com/chapter-1",+ title: "Chapter 1", persistedNote: "existing note", persistedRating: .up, persistedModifiedAt: Date(timeIntervalSince1970: 1_721_000_000),
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swiftindex 2859407..ce9f5a3 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift@@ -91,6 +91,10 @@ struct M5SeedEntry: Sendable { var note: String = "" var rating: Rating? var chapterTitle: String?+ /// Left nil, a supplied `chapterTitle` is stamped `.manual` — the shape most+ /// suites want. Set it to `.pattern` for a *parsed* title, which is derived+ /// and so takes no part in authored-content comparison (Q44).+ var chapterTitleProvenance: FieldProvenanceKind? var firstCapturedAt: Date = M5Fixture.epoch var lastSharedAt: Date = M5Fixture.epoch var workID: UUID?@@ -102,6 +106,7 @@ struct M5SeedEntry: Sendable { init( id: UUID, captureTitle: String, hostname: String, path: String, note: String = "", rating: Rating? = nil, chapterTitle: String? = nil,+ chapterTitleProvenance: FieldProvenanceKind? = nil, firstCapturedAt: Date = M5Fixture.epoch, lastSharedAt: Date = M5Fixture.epoch, workID: UUID? = nil, workRowIndex: Int = 0, workAssignmentProvenance: FieldProvenanceKind = .manual,@@ -114,6 +119,7 @@ struct M5SeedEntry: Sendable { self.note = note self.rating = rating self.chapterTitle = chapterTitle+ self.chapterTitleProvenance = chapterTitleProvenance self.firstCapturedAt = firstCapturedAt self.lastSharedAt = lastSharedAt self.workID = workID@@ -181,7 +187,11 @@ extension LibraryRepository { entry.lastSharedAt = seed.lastSharedAt entry.modifiedAt = seed.lastSharedAt entry.chapterTitle = seed.chapterTitle- if seed.chapterTitle != nil { entry.chapterTitleProvenance = .manual }+ if let provenance = seed.chapterTitleProvenance {+ entry.chapterTitleProvenance = provenance+ } else if seed.chapterTitle != nil {+ entry.chapterTitleProvenance = .manual+ } entry.intentionallyUnattached = seed.intentionallyUnattached context.insert(entry) // Rows this batch did not insert are resolved from the store, so
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swiftindex ecf9673..1e9e823 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift@@ -196,6 +196,7 @@ struct LookupFirstCaptureStateTests { entryID: fixture.entryID, hostname: "example.com", identityKey: "https://example.com/chapter-1",+ title: "Chapter 1", persistedNote: "concurrent change", persistedRating: nil, persistedModifiedAt: Date(timeIntervalSince1970: 1_721_500_000),@@ -286,6 +287,7 @@ private struct LookupCaptureFixture { entryID: entryID, hostname: "example.com", identityKey: "https://example.com/chapter-1",+ title: "Chapter 1", persistedNote: "existing note", persistedRating: .up, persistedModifiedAt: Date(timeIntervalSince1970: 1_721_000_000),@@ -305,6 +307,7 @@ private struct LookupCaptureFixture { entryID: entryID, hostname: "example.com", identityKey: "https://example.com/chapter-1",+ title: "Chapter 1", persistedNote: "note", persistedRating: rating, persistedModifiedAt: Date(timeIntervalSince1970: 1_721_000_000),
diff --git a/Asterism/AsterismTests/ExtensionLookupWiringTests.swift b/Asterism/AsterismTests/ExtensionLookupWiringTests.swiftindex ba4d05c..caff026 100644--- a/Asterism/AsterismTests/ExtensionLookupWiringTests.swift+++ b/Asterism/AsterismTests/ExtensionLookupWiringTests.swift@@ -68,7 +68,7 @@ struct ExtensionLookupWiringTests { func editDispositionDrivesEditState() async { let basis = ReShareEditBasis( entryID: UUID(), hostname: "example.com", identityKey: "k",- persistedNote: "note", persistedRating: .up,+ title: "Chapter 1", persistedNote: "note", persistedRating: .up, persistedModifiedAt: .now, firstCapturedAt: .now) let coordinator = RecordingCoordinator(disposition: .edit(basis)) let vm = LookupCaptureViewModel()
diff --git a/specs/sharesheet-polish/smolspec.md b/specs/sharesheet-polish/smolspec.mdnew file mode 100644index 0000000..5e15dff--- /dev/null+++ b/specs/sharesheet-polish/smolspec.md@@ -0,0 +1,121 @@+# Sharesheet Polish (T-2190)++## Overview++The share extension's capture sheet shows the page title twice — once raw+(`metadataSection`) and once parsed (`projectedMetadataSection`) — which is+redundant when a parsed version exists. The re-share edit sheet shows no title+at all, so the reader cannot tell which entry they are annotating. The rating+toggles sit below the note field and are easy to forget. This change removes+the duplicate title, adds a title to the edit sheet, and moves the rating+above the note field in both sheets.++## Requirements++- The capture sheet MUST hide the raw page title when a parsed title is shown+ (a taught site's projected chapter, or an articles outcome's display title —+ set on every articles capture, even when cleaning left the title unchanged),+ in both the ready and save-failed states.+- The capture sheet MUST keep showing the raw title when no parsed title+ exists: untaught sites, and taught sites whose title does not match the+ pattern (the parse-failure row).+- The capture sheet MUST NOT show a hostname row: the sharer is on the page+ they are sharing, so the site adds nothing (Q12, overruling Q2's hostname+ half after the device check). The manual-title input keeps showing+ regardless of parse state. With no banner, input, or raw title to show,+ the metadata card MUST be omitted entirely rather than render empty.+- The parsed card MUST list the work above the chapter (Q13): the work names+ what the capture belongs to, the chapter locates it within.+- The re-share edit sheet MUST show the entry's title: the parsed chapter+ title when one was stored, otherwise the raw capture title. A blank title+ MUST render nothing rather than an empty row.+- Both sheets MUST place the rating toggles above the note field.+- Existing behaviour MUST be otherwise unchanged: note auto-focus, save/stale/+ failure draft preservation, and accessibility identifiers keep working.++## Implementation Approach++Three mechanical changes in the share extension and its Core state types:++1. **Hide raw title when parsed exists** —+ `Asterism/AsterismShareExtension/CaptureView.swift`. Add a computed+ property `CaptureOutcome.hasParsedTitle` (`projectedChapter != nil ||+ displayTitle != nil`) in `Packages/AsterismCore/Sources/AsterismCore/+ ProjectionContract.swift` so the condition is testable in Core. Pass the+ already-in-scope `outcome` into `metadataSection` from both callers —+ `readyContent` (:125) and `failedContent` (:154) — and skip the+ `resolvedTitle` text (id `capture.pageTitle`) when+ `outcome?.hasParsedTitle == true`. The hostname row is removed outright+ (Q12) and the card skips rendering when neither banner, manual input, nor+ raw title fills it; the `manualTitleInput` branch is untouched. Parse+ failure leaves both parsed fields nil, so the raw title stays visible+ then. In `projectedMetadataSection` the work block precedes the chapter+ block (Q13).+2. **Title on the edit sheet** — add a `title: String` field to+ `ReShareEditBasis` (`Packages/AsterismCore/Sources/AsterismCore/+ LibraryRepository+Capture.swift:40-73`), populated in+ `EntryGroup.reShareBasis` (:311-321) as the carrier's `chapterTitle ??`+ the representative's `captureTitle` (chapter titles are authored content+ and read from the carrier, matching `snapshot(_:)`; the raw capture title+ is evidence from the representative — Q3. Torn groups dispatch to `.new`+ and never reach the edit sheet). Plumb it into `ReShareEditState`+ (`Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift`;+ the lookup and stale rebuilds construct it memberwise, while the failure+ rebuild mutates a copy of the current state, preserving the title — Q14). Render it in+ `ReShareCaptureView.editContent`+ (`Asterism/AsterismShareExtension/ReShareCaptureView.swift:115-130`) above+ the edit banner in `AsterismTypography.serifHeading`, id `reshare.title`,+ omitted when the string is empty (`captureTitle` defaults to `""` for+ title-less captures).+3. **Rating above note** — swap the `noteSection` / `ratingSection` order at+ `CaptureView.swift:132-133` (ready), `:158-159` (saveFailed), and+ `ReShareCaptureView.swift:125-129`. The sections are self-contained+ computed properties; the note's 0.3 s auto-focus (:387-391) is unaffected+ by its position.++Dependencies: existing types only (`CaptureOutcome`, `CapturePreparation`,+`EntryGroup`). Tests (state/repository level — the views have no UI-test+coverage, see `docs/agent-notes/composed-teaching-ui.md`):++- `hasParsedTitle` truth table in+ `Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift`,+ including an articles outcome whose clean was a no-op (raw title kept+ verbatim as `displayTitle` — still hides the raw row).+- Basis title population (taught fallback and raw fallback) in+ `Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryReShareTests.swift`,+ which drives `captureLookup` end-to-end.+- State plumbing in `ReShareExtensionUITests.swift`; memberwise-init fix-ups+ there (2 sites), in `LookupFirstCaptureStateTests.swift` (3), and in+ `Asterism/AsterismTests/ExtensionLookupWiringTests.swift` (1).++Out of Scope:+- Any change to how titles are parsed or stored, or to `CaptureOutcome`+ computation.+- The `projectedMetadataSection` layout (Article/Chapter/Work rows, parse+ failure row, review banner).+- A hostname row on the edit sheet — T-2190 asks for the title; the edit+ sheet is reached from a share of that exact URL, so the site is evident.+- XCUITest coverage of the share extension (not feasible; see agent note).+- The main app's capture/edit surfaces — this touches the share extension+ sheets only.++## Risks and Assumptions++- Risk: hiding the raw title on articles sites while the reader types a+ manual title could hide their input's echo. | Mitigation: only the static+ `resolvedTitle` text is conditional; the `manualTitleInput` field always+ renders when active.+- Risk: `ReShareEditBasis` is `public` with a memberwise `init`; adding a+ field breaks the six test construction sites listed above. | Mitigation:+ compiler-driven fix-ups; the field is non-optional `String` so no new nil+ states.+- Assumption: the carrier's `chapterTitle ??` the representative's+ `captureTitle` is the title the reader expects on re-share; it reproduces+ `snapshot(_:)`'s carrier/representative sourcing+ (`LibraryRepository+Groups.swift`). It is not Entry detail's full heading+ chain, which also falls back through `chapterSequence` and the cleaned+ articles `displayTitle` — an articles entry can show its cleaned title in+ the app and the raw one on the edit sheet (T-2194, Q3).+- Assumption: a parsed chapter always comes with a work name+ (`TitleRuleParseResult.workName` is non-optional), so hiding the raw title+ never removes the only place the story's name appears.
diff --git a/specs/sharesheet-polish/decision_log.md b/specs/sharesheet-polish/decision_log.mdnew file mode 100644index 0000000..3756b6f--- /dev/null+++ b/specs/sharesheet-polish/decision_log.md@@ -0,0 +1,20 @@+# Decision Log: Sharesheet Polish++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-14 | "Parsed title available" means `outcome.projectedChapter != nil \|\| outcome.displayTitle != nil` | Those are the two texts `projectedMetadataSection` renders as a title; parse failure leaves both nil so the raw title stays visible |+| Q2 | 2026-08-14 | Keep hostname and manual-title input visible even when the raw title is hidden | Hostname is not duplicated by the parsed card; the manual input is an editor, not an echo |+| Q3 | 2026-08-14 | Edit-sheet title comes from `carrier.chapterTitle ?? representative.captureTitle`, plumbed through `ReShareEditBasis`/`ReShareEditState` | Reproduces `snapshot(_:)`'s sourcing: for a split group the chapter title is read off the carrier because that is the row whose content the group presents — even though a *parsed* chapter title is derived (Q44 in duplicate-reconciliation) and does not itself select the carrier; the raw capture title is evidence from the representative (Q41). This is sourcing parity only — Entry detail's heading additionally falls back through `chapterSequence` and the cleaned articles `displayTitle` (`EntryDetailModel`), context `reShareBasis` does not hold; the articles divergence is T-2194 |+| Q4 | 2026-08-14 | Rating moves above the note in both `CaptureView` and `ReShareCaptureView` | T-2190 asks for the sharesheet generally; both sheets share the layout |+| Q5 | 2026-08-14 | Stay in smolspec despite touching 4 source files | The 4th file is one struct field plumbed through layers, not added complexity; a full spec would be disproportionate for a layout change |+| Q6 | 2026-08-14 | The hiding condition lives on `CaptureOutcome` (`hasParsedTitle`), not inline in the view | The views have no test coverage; a Core computed property gets a truth-table test in `CaptureStateTests` |+| Q7 | 2026-08-14 | An empty edit-sheet title renders nothing | `captureTitle` defaults to `""`; an empty serif row is worse than no row |+| Q8 | 2026-08-14 | No hostname row on the edit sheet | T-2190 asks for the title only; the edit sheet is reached by sharing that exact URL, so the site is evident |+| Q9 | 2026-08-14 | `hasParsedTitle` is true for every articles capture, including a no-op clean | `ArticleTitleCleaner.clean` is non-optional and the articles path always sets `displayTitle`; hiding the raw row is still a dedup because the Article row would repeat it verbatim. Pinned in the truth-table test |+| Q10 | 2026-08-14 | No guard needed for a chapter without a work name | `TitleRuleParseResult.workName` is non-optional, so a successful parse always carries the work name; hiding the raw title cannot remove the only story name on the sheet |+| Q11 | 2026-08-15 | An untrimmed whole-title rule still shows the page title twice (raw row plus an identical `Work:` row) | Knowingly out of scope: `hasParsedTitle` reads the two projected title texts, and a whole-title derivation sets neither. Do not widen it — for a *trimmed* whole-title rule the strings differ and hiding the raw one loses information |+| Q12 | 2026-08-15 | Remove the capture sheet's hostname row entirely; omit the metadata card when nothing else fills it | Device-check feedback: the sharer is on the page they are sharing, so the site adds nothing — chapter and work carry the information. Overrules the hostname half of Q2 (the manual-input half stands). CaptureView's private `hostname(from:)` helper and the extension's only `SiteGlyph` usage are deleted — `SiteGlyph` itself lives on in ConstellationKit with its main-app callers |+| Q13 | 2026-08-15 | The parsed card lists the work above the chapter | Device-check feedback: the work names what the capture belongs to, the chapter locates it within — an order the raw title used to obscure. The parse-failure row rides with the work block, so it now precedes the no-chapter row |+| Q14 | 2026-08-15 | The failure rebuild preserves the edit-sheet title; the stale rebuild refreshes it | A save failure changes nothing on disk, so the title the reader saw stands; a stale result means a concurrent writer changed the entry, and the refreshed basis's title matches what the reader is about to overwrite. Both paths pinned in ReShareExtensionUITests |
diff --git a/specs/sharesheet-polish/tasks.md b/specs/sharesheet-polish/tasks.mdnew file mode 100644index 0000000..f67f29b--- /dev/null+++ b/specs/sharesheet-polish/tasks.md@@ -0,0 +1,20 @@+---+references:+ - specs/sharesheet-polish/smolspec.md+ - specs/sharesheet-polish/decision_log.md+---+# Sharesheet Polish (T-2190)++- [x] 1. A parsed title suppresses the raw page title on the capture sheet: CaptureOutcome gains a hasParsedTitle property and metadataSection skips the resolvedTitle text when it is true, in both ready and save-failed states; hostname, manual-title input, and the parse-failure case keep the raw title visible (hostname later removed — task 6, Q12). Verified by a hasParsedTitle truth-table test in CaptureStateTests covering taught, articles (including a no-op clean that keeps the raw title verbatim as displayTitle), untaught, and parse-failure outcomes. <!-- id:hwy7vkd -->++- [x] 2. The re-share edit basis carries the entry title: ReShareEditBasis gains a non-optional title populated in EntryGroup.reShareBasis as the carrier's chapterTitle ?? the representative's captureTitle (matching snapshot's sourcing), plumbed into ReShareEditState at all three view-model construction sites; the six memberwise-init test call sites compile again. Verified in RepositoryReShareTests: an edit disposition returns the stored chapter title when one exists, else the raw capture title, and a split (non-torn) group reads the carrier's chapter title, not the representative's. <!-- id:hwy7vkb -->++- [x] 3. The edit sheet shows which entry is being annotated: ReShareCaptureView.editContent renders the state title above the edit banner in serif heading style with identifier reshare.title, rendering nothing when the title is blank. State plumbing (title present in readyEdit after lookup, preserved through stale and failure rebuilds) verified in ReShareExtensionUITests. <!-- id:hwy7vkc -->+ - Blocked-by: hwy7vkb (task 2 — edit basis carries the entry title)++- [x] 4. The rating toggles sit above the note field on both sheets: section order swapped in CaptureView ready and save-failed layouts and in ReShareCaptureView edit layout, with note auto-focus and all existing accessibility identifiers unchanged. The extension views have no automated UI coverage, so verification is by inspection plus task 5's make test-core run. <!-- id:hwy7vke -->++- [x] 5. The whole change passes the pre-commit bar: make test-core is green with no new compiler warnings, and no accessibility identifier referenced by existing tests has changed. <!-- id:hwy7vkf -->+ - Blocked-by: hwy7vkd (task 1 — parsed title suppresses raw title), hwy7vkb (task 2 — edit basis carries the entry title), hwy7vkc (task 3 — edit sheet shows the title), hwy7vke (task 4 — rating above note)++- [x] 6. Device-check feedback applied: the capture sheet's hostname row is gone (with the metadata card omitted entirely when no banner, manual input, or raw title fills it) and the parsed card lists the work above the chapter. Verified by make test-core plus a clean extension build; no test referenced the removed capture.hostname identifier.
diff --git a/specs/sharesheet-polish/implementation.md b/specs/sharesheet-polish/implementation.mdnew file mode 100644index 0000000..fe1b7a7--- /dev/null+++ b/specs/sharesheet-polish/implementation.md@@ -0,0 +1,267 @@+# Implementation Explanation: Sharesheet Polish (T-2190)++Explanation of `git diff origin/main..HEAD` (7 commits) at three expertise+levels, following the explain-like methodology, plus a completeness assessment+against the smolspec.++## Beginner Level++### What Changed++Asterism has a share extension: when you share a web page from Safari, a small+sheet pops up to save that page into your reading library. This change tidies+up two of those sheets.++1. **The capture sheet no longer says the page title twice.** Before, the+ sheet showed the raw browser-tab title (e.g. "Some Story - Chapter 5") in+ one card, and then a cleaned-up, parsed version of the same information+ ("Chapter: 5" under "Work: Some Story") in a second card. When the app has+ successfully parsed the title, the raw one is now hidden — you see each+ piece of information once. When the app could *not* parse the title (an+ unknown site, or a title that doesn't match the expected pattern), the raw+ title still shows, because then it is the only title you have.+2. **The hostname row is gone.** The sheet used to show the website's name+ (e.g. "example.com"). But you are literally on that page when you share it,+ so the row told you nothing new. With it gone, the whole card disappears+ when there is nothing left to put in it, instead of showing an empty box.+3. **The parsed card lists the work before the chapter.** "Work" is the story+ the chapter belongs to. Naming the story first, then the chapter within it,+ reads more naturally — like a book title before a page number.+4. **The edit sheet now shows a title.** If you share a page you already+ saved, you get an "edit" sheet to update your note or rating. It used to+ show no title at all, so you couldn't tell *which* entry you were editing.+ It now shows the entry's title at the top.+5. **The rating buttons moved above the note box** on both sheets. The note+ box pops up the keyboard, which covers the bottom of the screen — so+ anything below it was easy to miss. The rating now sits where you'll see+ it first.++### Why It Matters++The sheet is something you use dozens of times while reading. Removing+duplicate and useless information makes it faster to scan, and showing the+entry title on the edit sheet prevents the "wait, what am I editing?" moment.++### Key Concepts++- **Share extension**: a mini-app inside the main app that iOS runs when you+ tap Share in another app. It has its own small screens ("sheets").+- **Parsed title**: Asterism can be "taught" how a site formats its titles,+ so "Some Story - Chapter 5" becomes work = "Some Story", chapter = "5".+ For article sites it instead "cleans" the title (removing things like+ " | The Paper").+- **Truth table test**: a test that lists every input combination and the+ expected answer, so the rule ("when do we hide the raw title?") is pinned+ down completely.++---++## Intermediate Level++### Changes Overview++Four production files:++- `Asterism/AsterismShareExtension/CaptureView.swift` — the capture sheet.+ `metadataSection` gains an `outcome: CaptureOutcome?` parameter (both+ callers, `readyContent` and `failedContent`, already had it in scope). The+ raw-title `Text` is skipped when `outcome?.hasParsedTitle == true`; the+ hostname row and the private `hostname(from:)` helper are deleted; the+ whole metadata card is wrapped in a guard so it renders only when at least+ one of new-site banner, manual-title input, or raw title will fill it. In+ `projectedMetadataSection` the work block (and the parse-failure row that+ rides with it) moves above the chapter block. Section order flips from+ `noteSection / ratingSection` to `ratingSection / noteSection` in both the+ ready and save-failed layouts.+- `Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift` —+ `CaptureOutcome.hasParsedTitle`, a computed `projectedChapter != nil ||+ displayTitle != nil`. This is the hiding condition, deliberately placed in+ Core (Q6) because the extension views have no automated UI coverage, so a+ view-inline condition would be untestable.+- `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift`+ — `ReShareEditBasis` gains a non-optional `title: String`, populated in+ `EntryGroup.reShareBasis(hostname:)` as+ `carrier.chapterTitle ?? representative.captureTitle`.+- `Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift` —+ `ReShareEditState` gains the same `title` field. The initial lookup+ (`readyEdit`) and the stale rebuild construct the state memberwise — the+ stale path takes the *refreshed* basis's title, so a concurrent retitle+ shows up — while the save-failure rebuild mutates a copy of the current+ state, preserving the title along with the draft (Q14).+- `Asterism/AsterismShareExtension/ReShareCaptureView.swift` — `editContent`+ renders `state.title` above the edit banner in+ `AsterismTypography.serifHeading` with identifier `reshare.title`, omitted+ entirely when the string is empty (Q7 — `captureTitle` defaults to `""`).+ Rating/note order swapped here too.++### Implementation Approach++The design keeps all logic in testable Core types and leaves the SwiftUI+views as thin conditionals:++- **Hiding condition on the model, not the view** (Q6). `hasParsedTitle`+ reads only the two texts `projectedMetadataSection` renders as a title, so+ "raw title hidden" is exactly "the parsed card shows a title". Parse+ failure leaves both fields nil, which is what keeps the raw title visible+ for untaught sites and unmatched titles — no separate flag needed.+- **Title sourcing reproduces `snapshot(_:)`** (Q3). A logical entry can be a+ "group" of duplicate rows (a sync artifact). The *carrier* is the row whose+ authored content (note, rating, manual chapter title) the group presents;+ the *representative* is the stable identity row. The edit-sheet title reads+ `chapterTitle` off the carrier and falls back to `captureTitle` off the+ representative — the same split-group sourcing the app's snapshot uses, so+ the edit sheet names a split group the way the library does. Torn groups+ (authored content disagrees) dispatch to `.new` and never reach the edit+ sheet, so no torn-group title policy is needed.+- **Plumbing over lookup**: the view model never re-derives the title; it+ flows basis → state → view. The stale-rebuild path deliberately takes the+ refreshed basis's title rather than preserving the old one.++### Trade-offs++- A non-optional `String` title (empty = absent) instead of `String?` avoids+ a new nil state, at the cost of the view checking `isEmpty`. Six+ memberwise-init test call sites broke and were fixed compiler-driven — the+ smolspec priced this in as the main risk.+- `hasParsedTitle` was deliberately **not** widened to catch the untrimmed+ whole-title rule case (raw row + identical `Work:` row still both show —+ Q11): for a *trimmed* whole-title rule the two strings differ, and hiding+ the raw one would lose information. Known cosmetic gap, out of scope.+- The edit sheet shows the *raw* capture title for articles entries whose+ in-app heading uses the cleaned `displayTitle` — the basis carries+ `snapshot(_:)`'s sourcing only, not Entry detail's full fallback chain+ (`chapterSequence`, cleaned articles title). Tracked as T-2194 rather than+ silently extending the basis.++### Tests++- `CaptureStateTests`: a six-row `hasParsedTitle` truth table (taught,+ articles cleaned, articles no-op clean per Q9, the unreachable both-set row+ named as pinning the `||` itself, and two nil/nil rows named as site states+ the property cannot distinguish).+- `RepositoryReShareTests`: basis title = stored chapter title; falls back to+ the raw capture title; and a split-group case seeding a bare+ representative-ordered row plus a note-carrying carrier with *different*+ pattern-derived chapter titles, asserting the carrier's wins+ (mutation-verified: flipping carrier→representative fails only this test).+ `M5SeedEntry` gained `chapterTitleProvenance` so a seed can stamp+ `.pattern` — derived titles don't tear a group (Q44), which is what makes+ the differing-titles shape legal.+- `ReShareExtensionUITests` (state-level, despite the name): title present in+ `readyEdit` after lookup, replaced on stale rebuild, preserved on save+ failure.++---++## Expert Level++### Technical Deep Dive++The interesting part of an otherwise-mechanical layout change is where each+condition lives and what it can and cannot observe.++**`hasParsedTitle` is a projection predicate, not a site-state predicate.**+`projectedChapter != nil || displayTitle != nil` collapses four site states+into three observable shapes: taught+parsed (chapter set), articles+(displayTitle always set — `ArticleTitleCleaner.clean` is total, Q9, so even+a no-op clean hides the raw row, which is correct because the Article row+would repeat it verbatim), and nil/nil, which is *both* "untaught" and+"taught but the title missed the pattern". The truth table documents this+honestly: the two nil/nil rows are field-identical and named as+indistinguishable, and the both-set row is flagged unreachable (articles+nils `projectedChapter`, taught never sets `displayTitle`, a Site has one+mode) — it pins the `||`, not a domain state. The one hole is Q11: an+*untrimmed* whole-title rule derives a work title equal to the page title+while setting neither predicate input, so raw row and `Work:` row still+duplicate. Deliberately not fixed — widening the predicate to compare+strings would mis-handle the trimmed case, where hiding the raw title loses+the trimmed-away text.++**Card omission is a render-side guard, not model state.** The metadata card+guard (`siteStatus == .newSite || showsManualInput || showsRawTitle`)+mirrors exactly the three things the card can contain post-hostname-removal.+Note `showsManualInput` keeps the original `titleSource == .manual &&+resolvedTitle == nil` semantics — the manual input is an editor, not an+echo (Q2's surviving half), so parsed-title suppression can never hide the+user's own typing. The `capture.metadata` identifier survives but is now+conditional; `capture.hostname` is gone, and task 6 verified no test+referenced it. `SiteGlyph` usage and `hostname(from:)` died with the row.++**Carrier/representative sourcing is parity with `snapshot(_:)`, and only+that.** The review pass (e6341e3/ec9ff50) is worth reading: the first-cut+rationale claimed chapter titles are authored content and that the edit+sheet matches Entry detail. Both claims were wrong — a *pattern-derived*+chapter title is a derived field (duplicate-reconciliation Q44) that plays+no part in carrier selection or tear detection, and Entry detail's heading+chain additionally falls through `chapterSequence` and the cleaned articles+`displayTitle`, context `reShareBasis` doesn't hold. The corrected comment+claims sourcing parity only. The split-group test is the load-bearing+artifact here: it needs two rows with the *same* logical identity but+different pattern-derived `chapterTitle`s (legal only because derived+fields don't tear), a representative selected by capture-title sort order+carrying nothing authored, and a carrier selected by its note. The test was+mutation-verified — swapping carrier for representative in `reShareBasis`+fails it and nothing else — which is the right bar for a test whose whole+point is discriminating two usually-identical sources. `M5SeedEntry`'s new+`chapterTitleProvenance` (nil ⇒ legacy `.manual` stamp) exists solely to+make that shape seedable without disturbing existing suites.++**State rebuild asymmetry is intentional.** Failure rebuild copies+`editState.title` (nothing changed on disk); stale rebuild takes+`refreshedBasis.title` (the concurrent writer may have retitled). The three+`ReShareExtensionUITests` cases pin exactly this asymmetry.++### Architecture Impact++- `ReShareEditBasis`/`ReShareEditState` grow a field via public memberwise+ inits — a known-breaking change absorbed by six test fix-ups. No default+ value was added, so future construction sites are forced to decide the+ title rather than silently inheriting `""`.+- `CaptureOutcome` gains its first derived predicate; view logic that was+ previously untestable-by-construction now has a Core seam. This is the+ pattern to repeat for any future extension-view conditional.+- The extension views remain uncovered by automated UI tests (see+ `docs/agent-notes/composed-teaching-ui.md`); verification of pure layout+ (rating/note order, card omission) is by inspection plus `make test-core`.++### Potential Issues++- Q11's double title on untrimmed whole-title rules will look like a+ regression report waiting to happen; it is recorded as knowingly out of+ scope.+- T-2194: articles entries show the cleaned title in-app but the raw capture+ title on the edit sheet, because the basis doesn't carry `displayTitle`.+- T-2193: the capture page title's VoiceOver label ("Page title") is a+ pre-existing bug, surfaced during review, not introduced or fixed here.+- The stale-rebuild title replacement means the sheet's title can change+ under the user mid-edit — correct (it matches the entry they're about to+ overwrite) but potentially surprising.++---++## Completeness Assessment++Judged against the smolspec's requirements:++| Requirement | Status | Evidence |+|---|---|---|+| Hide raw title when a parsed title is shown, in ready and save-failed states | **Fully implemented** | `metadataSection(preparation:outcome:)` called from both `readyContent` and `failedContent`; `hasParsedTitle` truth table incl. articles no-op clean (Q9) |+| Keep raw title when no parsed title exists (untaught, parse failure) | **Fully implemented** | nil/nil truth-table rows; parse failure leaves both projected fields nil |+| No hostname row; card omitted entirely when empty | **Fully implemented** | Row, `hostname(from:)`, and `SiteGlyph` use deleted; card guarded on banner/manual-input/raw-title (Q12); no test referenced `capture.hostname` |+| Parsed card lists work above chapter | **Fully implemented** | Work block (with parse-failure row) moved above chapter block (Q13) |+| Edit sheet shows entry title; blank renders nothing | **Fully implemented** | `carrier.chapterTitle ?? representative.captureTitle` through basis/state/view, id `reshare.title`, `isEmpty` guard (Q7); repository, split-group (mutation-verified), and state-plumbing tests |+| Rating toggles above the note on both sheets | **Fully implemented** | Order swapped at all three layout sites; verification by inspection + `make test-core` (views have no UI coverage — a stated, accepted limitation, not an omission) |+| Existing behaviour otherwise unchanged (auto-focus, draft preservation, accessibility identifiers) | **Fully implemented** | Note auto-focus untouched; stale/failure rebuild tests preserve note/rating behaviour; only deliberate identifier change is the removed `capture.hostname` |++No requirement is unexplainable from the code, and no implemented logic+diverges from the spec. Known deliberate gaps, recorded rather than+rediscovered:++- **T-2194** — articles entries can show the cleaned `displayTitle` in-app+ but the raw capture title on the re-share edit sheet; the basis reproduces+ `snapshot(_:)`'s sourcing only (Q3).+- **T-2193** — pre-existing VoiceOver label bug on the capture page title,+ filed from review, out of scope here.+- **Q11** — an untrimmed whole-title rule still shows the page title twice+ (raw row plus an identical `Work:` row); knowingly out of scope because+ widening `hasParsedTitle` would mis-handle trimmed whole-title rules.
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex d387abf..9dcdcf4 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -17,6 +17,7 @@ | [URL Locator Generalisation](#url-locator-generalisation) | 2026-08-08 | Done — re-teaching the four sites remains (prerequisites.md) | Adds an unanchored side to a path locator, so a taught rule stops pinning itself to the story it was taught from. Every rule in the library is pinned today: `tapas.io` resolves a URL identity on 1 capture of 69, `royalroad` on 9 of 15. Ships with the fix for `URLRulePattern.definition`, which fabricates a rule from bytes it cannot decode and can bake that fabrication into a backup (Decision 5). | | [Teach Editor Authoring Gaps](#teach-editor-authoring-gaps) | 2026-08-10 | Done — all 12 tasks complete 2026-08-12; `make test-core` and `make test-quick` green with zero new warnings (verified against a forced recompile). The AsterismCore non-goal was waived once for the folded-in Req 3.21 Work-rename fix (Q31); the tapas flow verified on-device | Closes the two authoring gaps the first real repair session surfaced (T-2135, Q25 of URL Locator Generalisation): a per-side anchoring choice for URL path locators — with the chapter-slot last-component default that fixes the chapter-nudge collision, and the blank-neighbour builder defect retired — and prefix/suffix trims on the positional segment title forms, so tapas' two title families parse with one rule. Editor-only; the representation already carries both capabilities end-to-end. | | [Configurable Work Types](#configurable-work-types) | 2026-08-13 | Done — all 21 tasks complete 2026-08-14; core, unit, and full UI suites green with no new warnings (verification-run.md). One release gate open (Q54): first entity added under live CloudKit mirroring, pre-feature-build mirror coexistence unverified — needs an approved physical-device check before release | Makes the work-type list user-configurable (T-2076): a `WorkTypeEntity` synced via a schema V6 bump, works referencing a type identity by UUID beside the kept `typeRaw` compatibility column, soft removal with restore and deterministic name convergence, seeds `novel`/`webtoon`/`article`, a settings management screen, a blank-default editor picker, and a 5/6 backup format carrying the type list. Legacy-typed works are deliberately not migrated (Decision 7). |+| [Sharesheet Polish](#sharesheet-polish) | 2026-08-14 | Done — all 6 tasks complete 2026-08-15, the last from the on-device check; `make test-core` and `make test-quick` green, no new warnings; post-review fixes applied with mutation-verified test coverage | Smolspec (T-2190): the capture sheet hides the raw page title whenever a parsed title is shown (taught chapter or articles display title, even a no-op clean — Q9) and drops its hostname row outright (Q12); the parsed card lists the work above the chapter (Q13); the re-share edit sheet gains the entry's title (`carrier.chapterTitle ?? representative.captureTitle`, `snapshot(_:)`'s sourcing — Q3, articles divergence deferred to T-2194); rating toggles move above the note field on both sheets. | --- @@ -271,3 +272,11 @@ Makes the work-type list user-configurable (T-2076): a synced `WorkTypeEntity` i - [decision_log.md](configurable-work-types/decision_log.md) - [tasks.md](configurable-work-types/tasks.md) - [verification-run.md](configurable-work-types/verification-run.md)++## Sharesheet Polish++Smolspec (T-2190): stops the capture sheet naming the page twice — the raw title hides whenever a parsed title is shown, including an articles clean that changed nothing (Q9) — and, after the on-device check, drops the hostname row entirely (Q12: the sharer is on the page they are sharing) with the work listed above the chapter (Q13). Gives the re-share edit sheet the title of the entry being annotated (the carrier's chapter title, else the representative's capture title, matching `snapshot(_:)`'s sourcing; the articles cleaned-title divergence is T-2194), and moves the rating toggles above the note field on both sheets. Follow-ups filed from review: T-2193 (pre-existing VoiceOver label on the capture page title), T-2194.++- [smolspec.md](sharesheet-polish/smolspec.md)+- [decision_log.md](sharesheet-polish/decision_log.md)+- [tasks.md](sharesheet-polish/tasks.md)
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d4b56b2..97cd41e 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -66,6 +66,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- The share sheet says each thing once and asks for the rating where you'll see it (T-2190, `specs/sharesheet-polish/`). When a capture has a parsed title — a taught site's chapter, or an articles site's cleaned title — the raw page title no longer repeats above it; it still appears when nothing parsed (untaught sites, or a title the rule couldn't read), and the manual-title field is untouched. The hostname row is gone altogether — you're on the page you're sharing, so the sheet spends its space on the work and chapter instead, listing the work first. The edit sheet you get when re-sharing a saved entry now shows which entry you're annotating, titled by its stored chapter title or, failing that, the title it was captured under. And on both sheets the rating toggles sit above the note field instead of below it, so they're in view before the keyboard comes up for the note. - The Settings screen is restructured around what a reader opens it for (T-2117, `specs/settings-cleanup/`). Sites now leads the screen; backup export and import share one "Backup" section, with the interrupted-import notice beside the Import control that repairs it; and the diagnostic surfaces — the iCloud sync status rows and Check Library — move behind a "Debug" disclosure at the bottom, collapsed by default. No wording or behaviour changed: the sync counts still load while the section is collapsed, and a blocked export still names its count and routes to Check Library from the Backup section itself. - The design document and style guide now say what shipped (M5 Documentation phase, `specs/polish-and-export/`): amber is documented as "actionable attention" rather than teaching-only, the standalone per-entry export's work line is specified with its omission cases, export's `firstCapturedAt` ordering is named where it was ambiguous, and the articles-mode exit is recorded in the M5 summary. - Backup export no longer refuses a library just because sync duplicated something in it (M4c phase 5, `specs/duplicate-reconciliation/`). Export used to refuse on *any* repeated record; it now projects a duplicated record into the archive as the one record it is, and refuses only where two copies genuinely disagree — naming how many, and pointing at Check Library, with a button to get there. If those copies are waiting on a duplicated work to be merged first, it says so. Once you have resolved the last disagreement the next export succeeds. Two copies of a taught rule export as one rule, keeping whichever copy the site actually teaches from.
The rating-above-note reorder exists to keep the rating visible; the note is now the last element and auto-focuses after 0.3 s. The extension has no UI-test coverage, so only a human eye on both sheets with the keyboard up can confirm the rating (and the new edit-sheet title) stay in view. Two Development builds were installed during this branch and the sheets checked — worth one more look at the keyboard case specifically before merge.
Three of seven full make test-core runs tonight exited non-zero with "1 issue" that no individual test reported, then passed cleanly on re-run — including runs before any of these changes landed in Core. Not caused by this branch (two failing runs predate the Core edits; the views aren't in the package), but worth watching. The final verification run passed all 1,503 tests.
T-2193: CaptureView's accessibilityLabel("Page title") replaces the title text for VoiceOver (pre-existing). T-2194: an articles entry shows its uncleaned title on the edit sheet where the app shows the cleaned one. Q11: an untrimmed whole-title rule still shows the page title twice.