Caps the Recent list at the newest 100 logical rows — by count alone, with no age cutoff — and puts everything beyond it one tap away in Works. Applied at the view layer, so AsterismCore, the fixtures and the performance suites are untouched.
allRows teaching lookups, the banner-filter bypass, and full-library search — so a fetchLimit would have shrunk the banner counts and stranded counted duplicate sets (Decision 1).isBounded = !showingActionableOnly && !showingDuplicatesOnly && !searchFilter.isActive — filters and search see the whole library, so the cap never hides a match.some View had zero coverage; RecentDisplayPlan now has 21 tests.O(review items) walk running on every keystroke, and a production limit of 100 that no test pinned (the suite would have stayed green if the default moved to 500).Ready to push
Four parallel review agents (reuse, quality, efficiency, spec/docs) raised 19 findings and no blockers. All were resolved: 13 fixed in 1719d75, 2 more in 88181fd, and 4 skipped with recorded reasons. The full suite passes with a forced recompile and a known-warning control file, so the “no new warnings” claim is real rather than a warm-DerivedData false green.
Two coverage gaps are accepted and written down rather than papered over: Reqs 2.1/2.2 are guarded by a code comment with no regression test (the plan does not own those counts, so a plan-level test would be vacuous), and Req 6.1's publish budgets were not re-measured. The second is defensible on evidence — zero files under Packages/ changed, and every budget times repository.recentPresentation(calendar:), which no app-target code enters.
e1cca19 Spec the Recent row cap — requirements, design, and tasks 845ef1e Cap the Recent list at the newest 100 rows 97f5b76 Apply design-critic review fixes ddba81d Changelog and specs overview 1719d75 Apply pre-push review fixes 88181fd Add implementation explanation and close review open questions RecentRowCap.swift
Why it matters. Correctness and performance. Returns the capped groups and the excluded count from one walk, so the two can never disagree. The loop deliberately continues past a filled cap rather than breaking, keeping the total complete at O(day groups) — never allRows.count, which is groups.flatMap(\.rows) and would allocate the whole library on every body evaluation.
What to look at. Asterism/Asterism/ViewModels/RecentRowCap.swift:35-62
RecentView.swift
Why it matters. API surface and testability. Absorbs four computed properties (displayGroups, searchedGroups, showsElsewhereSection, showsSearchEmptyState) and the if/else-if/else ladder into one value type, preserving branch order exactly — emptyLibrary still outranks searchMiss.
What to look at. Asterism/Asterism/Views/RecentView.swift:524-636
RecentView.swift
Why it matters. User-visible behaviour, carrying six acceptance criteria (2.3-2.5, 4.1-4.3). If this line inverted, a search would silently stop finding anything older than the newest 100 rows — the worst possible failure for this feature, and invisible.
What to look at. Asterism/Asterism/Views/RecentView.swift:582-583
RecentView.swift
Why it matters. Performance regression caught in review. RecentDuplicatePlan was being built unconditionally and then discarded unless the duplicate filter was on. The code it replaced short-circuited, so in the normal case it was never built at all — and body re-evaluates on every character typed in the search field.
What to look at. Asterism/Asterism/Views/RecentView.swift:566-576
ContentView.swift
Why it matters. User-visible behaviour and the whole point of Req 1.3. The footer must land on an unfiltered Works root — a stale query would hide Unattached Notes, i.e. hide exactly the rows the reader tapped the footer to find.
What to look at. Asterism/Asterism/ContentView.swift:458-464 and :270
RecentDisplayPlanTests.swift
Why it matters. Correctness of the test suite itself. Every other plan test passes limit: 2 for readability, so before this test the entire suite would have stayed green if RecentDisplayPlan's default moved off 100. The requirement that the screen caps at 100 was enforced nowhere.
What to look at. Asterism/AsterismTests/RecentDisplayPlanTests.swift:76-90
M4ScaleRecentPerformanceUITests.swift
Why it matters. Diagnostic integrity plus a 6x speedup on the guard. The seeding test exists to distinguish a throwing seeder from a waitForExistence timeout; bolting a navigation journey onto it meant a journey flake would report red against the seeding guard. Split, it runs in 11.5 s instead of ~74 s.
What to look at. Asterism/AsterismUITests/M4ScaleRecentPerformanceUITests.swift and UIJourneySupport.swift:178-200
Four requirements independently need the complete post-derivation row set: library-wide banner counts (2.1), allRows teaching lookups (2.2), the banner-filter bypass (2.3) and full-library search (4.1).
Rejected alternatives: a fetchLimit (shrinks the banner counts and strands counted duplicate sets); truncating row emission (kills re-teach for sites entirely beyond the cap); a second bounded read (opens a split moment between the counts and the rows). Consequence: AsterismCore, the fixtures and the performance suites are untouched.
A 14-day window was specified alongside the cap and then dropped. It would have emptied Recent for a reader returning after a break — the one reader guaranteed to encounter it. Removing it also removed the repository clock, the calendar arithmetic, a DST case, and every fixture and performance-suite change the window would have forced.
Capping raw records would let a duplicated entry consume several of the 100 slots, so the reader would see fewer than 100 distinct things.
A scoped supersession of library-integrity-tolerance Req 2.2: an unresolvable entry beyond the newest 100 is no longer listed or attention-marked in Recent, and per-record marking exists nowhere else. Coverage falls back to the aggregate path — diagnosis banner → Library Check. That spec has been annotated in place.
Hoisting WorksView's query into a parent Binding would rebuild ContentView — and RecentView with it — on every keystroke in Works search.
The Unattached Notes header renders below all 1,000 seeded work rows in a lazy List and never enters the accessibility tree — the first run failed on exactly that. Consequence, recorded: Req 1.3's “including Unattached Notes” is asserted only by proxy. The general behaviour is separately covered on a small fixture by SearchUITests.
Verified against the diff rather than assumed: zero files under Packages/ changed. Both host suites time repository.recentPresentation(calendar:) directly and the device signpost brackets that same function, so nothing any budget times executes app-target code. make test-performance-m4 is host-only and safe but ~20 minutes.
Production only ever passes the compile-time rowLimit; no view code can reach it with a computed value. Clamping with max(1, limit) would silently accept nonsense instead of surfacing a programming error.
The unbounded branch already forces excludedRowCount to zero, so isBounded && excludedRowCount > 0 reduces to the second conjunct. Read as stated for readability against Req 3.3 rather than as a live condition.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | M4ScaleRecentPerformanceUITests.swift — test design | A Req 3.2 navigation journey (tab switching, typing, pushing a detail, ~38 swipes) was bolted onto testSeededScaleM4ScenarioReachesRecent, whose documented job is to catch a throwing seeder that would otherwise be misreported as a timeout. Any journey flake would report red against the seeding guard. Raised independently by the efficiency and quality reviewers. | Split into a dedicated testTruncationFooterOpensWorksRoot reusing launchFreshScaleFixture. The seeding guard is back to its original scope and runs in 11.5 s instead of ~74 s. |
| major | M4ScaleRecentPerformanceUITests.swift — scroll cost | The footer was located with anyElement, a .any descendant query forcing a full accessibility snapshot per exists check (~500-1000 nodes), inside a hand-rolled 30-pass swipe loop that ran a second scroll driver beside the shared helper's own. | scrollUntilTappableAndTap gained defaulted attempts: and scroll: parameters; the pre-loop is deleted and the footer is found via a typed app.buttons[…] query. One driver, far cheaper predicate. |
| major | specs/ — cross-spec supersession | The spec supersedes clauses in three other specs one-way, without touching any of them. The repo's one prior precedent (relational-references) edits the target in place with a forward-pointing WITHDRAWN block. | Added matching "Superseded in part (2026-08-15)" annotations in place to immutable-capture-safety-net Req 5.1, title-teaching-retroactive-parsing Req 10.4 and library-integrity-tolerance Req 2.2. Originals not rewritten. |
| major | RecentView.swift — code reuse | The truncation footer reproduced elsewhereSection's button chrome token for token, differing on only four attributes. The same file already carries a bannerButton extraction whose doc comment records three copied banners having drifted, one hardcoding 44 instead of AsterismLayout.minHitTarget. Raised independently by the reuse and quality reviewers. | Extracted summaryRow(icon:iconTint:text:showsChevron:borderColor:identifier:accessibilityLabel:action:) beside bannerButton; both rows now build from it. Rendered output and accessibility identifiers unchanged. |
| major | UIJourneySupport.swift — latent bug | The copied worksSearchField helper dropped the `if !field.isHittable { app.swipeDown() }` guard that the original applies before tapping. In a scrolled list the field exists but is not hittable, so the tap silently misses. | Lifted searchField into UIJourneySupport.swift carrying both the collapsed-field wait and the hittability guard; both private copies deleted and SearchUITests now uses the shared one. |
| minor | RecentDisplayPlanTests.swift — coverage | Every banner-filter fixture used a single day group, so a regression that flattened day grouping while filtering would have passed (Req 2.3). | Added bannerFilterPreservesDayGroups: an actionable filter across two day groups asserting both group days and per-group row membership survive. |
| minor | RecentRowCapTests.swift — coverage | Req 1.1's "no age-based exclusion" clause held only structurally — no clock or calendar is in scope — with no positive test, so introducing an age cutoff would have broken nothing. | Added ageAloneNeverExcludes: decade-old rows inside the limit are kept, so a future cutoff has to fail a test rather than merely be absent from the type. |
| minor | decision_log.md — format | The log ran Decision 2, Decision 1, then the Quick Decisions table. The format reference and every sibling log put the Quick Decisions table under the header, then full entries ascending. Q29/Q30 also sat out of order between Q22 and Q23. | Reordered to match, and added Q31 (placeholder assertion), Q32 (scroll budget) and Q33 (Req 6.1 not re-measured, with the verified argument). |
| minor | docs/agent-notes/testing.md | A store-digest flake in BootstrapActionTests fired twice during this work (a different cell each run, green in isolation and on clean HEAD). The same root cause was documented only inside another spec's files, and only for BootstrapClassifierTests — undiscoverable from agent-notes. | Added one section covering the digest-flake family across both suites: root cause (byte digest over the store family, cross-suite SQLite WAL checkpoint sensitivity), how it presents, and the suggested hardening. |
| minor | specs + CHANGELOG.md — accuracy | design.md and tasks.md still described the excluded-row total as coming from groups.reduce, though the code accumulates it in the single walk (97f5b76 fixed the code comment but not the spec text). tasks.md task 7 still described the superseded "Unattached Notes present" assertion. The changelog's test counts were wrong and it described the M4 journey as an extension of the seeding guard after the split. | All corrected. Task completion state untouched and rune list re-verified. |
| minor | ContentView.swift — comment accuracy | showWorksRoot()'s comment claimed three load-bearing explicit clears, but selectedWorkChapterEntryID is already covered by the existing onChange(of: selectedWorkID). The .id(worksResetToken) comment justified the token only as a query reset, omitting that it rebuilds the whole subtree and discards scroll position. | Both comments rewritten to state what actually happens, including the deliberate duplication and the rebuild cost. |
| nit | RecentView.swift | @ViewBuilder on truncationFooter is unnecessary — its body is a single unconditional Section, unlike elsewhereSection which has an if. | Removed. |
| nit | RecentRowCap.swift — comment drift | The doc comment said the total was taken from groups.reduce; the code accumulates it in the loop. | Comment corrected to match the code (the loop is the intended design per Q30). |
| nit | RecentView.swift — isolation comment | The nonisolated justification on Branch claimed a MainActor Equatable conformance would be an error under the Swift 6 language mode, but the test target is SWIFT_VERSION = 5.0 while the app target sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. | Reworded to state the real mechanism and why it compiles today. Isolation itself deliberately not restructured. |
| major | RecentView.swift — per-keystroke cost (search) | Under an active query the plan takes all groups into RecentSearchFilter.apply, which walks every row running up to three locale-aware substring searches. At 5,000 entries that is up to 15,000 normalized searches on the main thread per character typed. | Skipped — not a regression (the pre-change displayGroups/searchedGroups did the same) and required by Req 4.1, which mandates full-library matching displayed regardless of the cap. Capping search results would violate the spec. Recorded for future work; the requirement-compatible fix is debouncing the query into a second @State. |
| minor | RecentRowCap.rowLimit — test speed | Making the row limit overridable from a launch argument would let the footer journey run against a 3-row fixture, removing ~60 s of scrolling. | Skipped — a production hook existing purely for test speed. Splitting the test addressed the diagnostic concern, which was the substantive half of the finding. |
| nit | RecentRowCap.swift — allocation | reserveCapacity(groups.count) over-reserves; at most min(groups.count, limit) groups are ever appended (~8 KB vs ~1 KB at 500 day groups). | Skipped — unmeasurable, and this path only runs when search is inactive. The reviewer flagged it as a nit and recommended against churn. |
| nit | SearchFilters / RecentDisplayPlan / RecentRowCap | The "filter rows, drop empty groups, rebuild the group" shape now appears in three places. | Skipped — the pattern is pre-existing and carried over from RecentView.displayGroups, not introduced by this branch. Unifying it is a separate change with its own blast radius. |
Click to expand.
diff --git a/Asterism/Asterism/Views/RecentView.swift b/Asterism/Asterism/Views/RecentView.swiftindex ffea745..a776aa5 100644--- a/Asterism/Asterism/Views/RecentView.swift+++ b/Asterism/Asterism/Views/RecentView.swift@@ -27,6 +27,9 @@ struct RecentView: View { let onShowSyncSettings: (() -> Void)? /// Req 9.2's inline route: the resolution surface for one duplicate set. let onResolveDuplicate: ((DuplicateSetKey) -> Void)?+ /// Req 3.2's route out of the truncation footer: the Works section's root,+ /// with no search filter applied. Without it the footer is not rendered.+ let onShowWorks: (() -> Void)? /// Req 9.1's count, which is the workload's plus the conflicts the app model /// is holding — the two halves are published from different places and are /// overlaid by the caller, not here.@@ -53,7 +56,8 @@ struct RecentView: View { onTeach: ((UUID) -> Void)? = nil, onShowDiagnostics: (() -> Void)? = nil, onShowSyncSettings: (() -> Void)? = nil,- onResolveDuplicate: ((DuplicateSetKey) -> Void)? = nil+ onResolveDuplicate: ((DuplicateSetKey) -> Void)? = nil,+ onShowWorks: (() -> Void)? = nil ) { self.presentation = presentation self.capabilities = capabilities@@ -66,75 +70,49 @@ struct RecentView: View { self.onShowDiagnostics = onShowDiagnostics self.onShowSyncSettings = onShowSyncSettings self.onResolveDuplicate = onResolveDuplicate- }-- /// Filtered groups respecting the two filters while preserving order.- ///- /// Req 9.1 routes the duplicate banner "to the affected records", which is- /// its own filter rather than a widening of the actionable one: a row can be- /// in a review set without needing teaching, and folding the two together- /// would make the actionable count and the rows it filters to disagree.- private var displayGroups: [RecentPresentationGroup] {- guard showingActionableOnly || showingDuplicatesOnly else { return presentation.groups }- return presentation.groups.compactMap { group in- let filtered = group.rows.filter { row in- (showingActionableOnly && row.isActionable)- || (showingDuplicatesOnly && row.duplicateRoute != nil)- }- guard !filtered.isEmpty else { return nil }- return RecentPresentationGroup(day: group.day, rows: filtered)- }+ self.onShowWorks = onShowWorks } private var searchFilter: RecentSearchFilter { RecentSearchFilter(query: searchQuery) } - /// Req 3.2: search runs **after** the banner filters, so both conditions- /// apply. Never before — the unfiltered set would surface a row the banner- /// had deliberately excluded.- private var searchedGroups: [RecentPresentationGroup] {- searchFilter.apply(to: displayGroups)- }-- /// Q13: the Elsewhere lines are summary sentences with no titles to match,- /// so a query never hides them. That also decides the search-empty branch:- /// while those lines are on screen the banner's count is still accounted- /// for, and replacing the list with an empty state would break the "every- /// counted item is a row or a line" invariant (Q39).- private var showsElsewhereSection: Bool {- showingDuplicatesOnly && !duplicatePlan.elsewhere.isEmpty- }-- /// Req 3.4: a query with no matches gets a message, not a blank list. The- /// empty branch above cannot serve — it keys on the library being empty and- /// never fires for a no-match query.- private func showsSearchEmptyState(for groups: [RecentPresentationGroup]) -> Bool {- searchFilter.isActive && groups.isEmpty && !showsElsewhereSection+ /// Every branch, row and summary line this screen renders (Q26). Built once+ /// per `body` evaluation and bound to a local below — reading it as a+ /// computed property three times would run the filter three times per+ /// keystroke.+ private var displayPlan: RecentDisplayPlan {+ RecentDisplayPlan(+ presentation: presentation,+ showingActionableOnly: showingActionableOnly,+ showingDuplicatesOnly: showingDuplicatesOnly,+ searchFilter: searchFilter,+ conflictCount: conflictCount,+ hasWorksRoute: onShowWorks != nil) } var body: some View {- // Computed once per body evaluation: the empty check and the ForEach- // below both consume it, and the filter runs per keystroke.- let searchedGroups = self.searchedGroups+ let plan = displayPlan // The banner region sits **above** the empty-library branch (Q15). Two // Site rows with no Entries yet — the first-sync shape — produce a // diagnosis and zero Recent groups, so a banner living in the non-empty // branch would leave that library with no route to Req 4.1's screen at // all.+ //+ // The banners themselves keep reading `presentation.actionableCount`,+ // `presentation.diagnosisCount` and `duplicateCount` directly: Reqs 2.1+ // and 2.2 hold only while the cap stays out of the counts. VStack(spacing: 0) { banners - // The filtered state keeps the list even where the library has no- // rows to show in it: what the banner counted may live entirely- // outside Recent, and the section below is the only route to it.- if presentation.groups.isEmpty && !showingDuplicatesOnly {+ switch plan.branch {+ case .emptyLibrary: emptyBranch- } else if showsSearchEmptyState(for: searchedGroups) {+ case .searchMiss: searchEmptyBranch- } else {+ case .list: List {- ForEach(Array(searchedGroups.enumerated()), id: \.element.day) { index, group in+ ForEach(Array(plan.groups.enumerated()), id: \.element.day) { index, group in Section { ForEach(group.rows) { row in RecentEntryRow(@@ -156,7 +134,8 @@ struct RecentView: View { .accessibilityIdentifier("recent-section-header") } }- if showingDuplicatesOnly { elsewhereSection }+ elsewhereSection(plan.elsewhere)+ if plan.showsFooter { truncationFooter } } .listStyle(.plain) // Req 8.1: the sky is the tab stack's background and the@@ -304,6 +283,52 @@ struct RecentView: View { .accessibilityLabel(accessibilityLabel) } + /// The one summary-row language, built once — the same lesson `bannerButton`+ /// above records, applied to the rows at the *end* of the list.+ ///+ /// The Elsewhere lines and the truncation footer are the same row: a caption+ /// glyph, a callout line in note text, and a card with a minimum hit target,+ /// inside a plain button that clears its list-row chrome. They differ only in+ /// glyph, glyph tint, whether the row ends in a chevron, and whether the card+ /// takes the amber attention edge. Rendered output and accessibility+ /// identifiers are unchanged — the UI tests key on both.+ private func summaryRow(+ icon: String,+ iconTint: Color,+ text: String,+ showsChevron: Bool,+ borderColor: Color?,+ identifier: String,+ accessibilityLabel: String,+ action: @escaping () -> Void+ ) -> some View {+ Button(action: action) {+ HStack(spacing: 8) {+ Image(systemName: icon)+ .font(.caption)+ .foregroundStyle(iconTint)+ .accessibilityHidden(true)+ Text(text)+ .font(.callout)+ .foregroundStyle(AsterismColors.noteText)+ Spacer()+ if showsChevron {+ Image(systemName: "chevron.right")+ .font(.caption2.weight(.semibold))+ .foregroundStyle(AsterismColors.secondaryText)+ .accessibilityHidden(true)+ }+ }+ .frame(minHeight: AsterismLayout.minHitTarget)+ .padding(.horizontal, 12)+ .constellationCard(borderColor: borderColor)+ }+ .buttonStyle(.plain)+ .constellationListRow()+ .accessibilityIdentifier(identifier)+ .accessibilityLabel(accessibilityLabel)+ }+ private var actionableBanner: some View { bannerButton( // §7/§8: the inbox banner's glyph is the app's ✦ mark. The filter@@ -351,44 +376,30 @@ struct RecentView: View { /// /// Without it the count and the filter disagree: a Work set or a preserved /// edit is counted, the reader taps, and every row is filtered away — a- /// banner reading "Showing 1 to resolve" over nothing.- private var duplicatePlan: RecentDuplicatePlan {- RecentDuplicatePlan(- workload: presentation.duplicateWorkload, conflictCount: conflictCount)- }-+ /// banner reading "Showing 1 to resolve" over nothing. The lines come from+ /// the display plan, which builds them only while the duplicate filter is+ /// active. @ViewBuilder- private var elsewhereSection: some View {- let elsewhere = duplicatePlan.elsewhere+ private func elsewhereSection(_ elsewhere: [RecentDuplicatePlan.Elsewhere]) -> some View { if !elsewhere.isEmpty { Section { ForEach(elsewhere) { item in- Button {+ summaryRow(+ icon: "square.on.square.dashed",+ iconTint: AsterismColors.amberText,+ text: item.text,+ showsChevron: false,+ // An actionable-attention row (Decision 1), so it takes+ // the amber border the unparsed rows wear.+ borderColor: AsterismColors.amber.opacity(0.3),+ identifier: "duplicate-elsewhere-row",+ accessibilityLabel: item.text+ ) { switch item.route { case .resolve(let key): onResolveDuplicate?(key) case .checkLibrary: onShowDiagnostics?() }- } label: {- HStack(spacing: 8) {- Image(systemName: "square.on.square.dashed")- .font(.caption)- .foregroundStyle(AsterismColors.amberText)- .accessibilityHidden(true)- Text(item.text)- .font(.callout)- .foregroundStyle(AsterismColors.noteText)- Spacer()- }- .frame(minHeight: AsterismLayout.minHitTarget)- .padding(.horizontal, 12)- // An actionable-attention row (Decision 1), so it takes- // the amber border the unparsed rows wear.- .constellationCard(borderColor: AsterismColors.amber.opacity(0.3)) }- .buttonStyle(.plain)- .constellationListRow()- .accessibilityIdentifier("duplicate-elsewhere-row")- .accessibilityLabel(item.text) } } header: { ConstellationSectionHeader("Elsewhere", accent: .violet)@@ -397,6 +408,31 @@ struct RecentView: View { } } + /// Req 3.1's disclosure: the list is capped, and the rest of the library is+ /// in Works. A final row in the same summary shape the Elsewhere lines use —+ /// a row at the end of the list carrying a route, not a new visual idiom.+ ///+ /// "Older" is accurate to the day granularity the list presents; at an exact+ /// timestamp tie the order falls to the identifier tiebreak, which no reader+ /// can observe.+ private var truncationFooter: some View {+ Section {+ summaryRow(+ icon: "sparkles",+ iconTint: AsterismColors.cyan,+ text: "Older entries are in Works",+ showsChevron: true,+ // Nothing is wrong here — the list is simply bounded — so this+ // row takes the plain card rather than the amber attention edge.+ borderColor: nil,+ identifier: "recent-truncation-footer",+ accessibilityLabel: "Older entries are in Works. Tap to open Works."+ ) {+ onShowWorks?()+ }+ }+ }+ /// Where a row's Resolve pill goes (Req 9.2). A deferred set routes at the /// Work set blocking it (Q45), because its own sheet is unreachable until /// that one resolves.@@ -472,6 +508,133 @@ struct RecentView: View { } } +/// What the Recent screen renders: which branch, which rows, which summary+/// lines, and whether the truncation footer appears.+///+/// A value type in the same mould as `RecentDuplicatePlan` because no app-layer+/// test can assert which branch a `some View` took (Q26) — a decision left+/// inside `body` is a decision with no coverage.+///+/// It performs the filtering rather than receiving its result: the search-miss+/// branch depends on emptiness *after* filtering, so a type that took+/// pre-filtered groups and also decided that branch would be circular.+///+/// No `Equatable` conformance: nothing compares plans, and a synthesised one+/// would deep-compare up to the whole library.+struct RecentDisplayPlan {+ /// `nonisolated` because the app target sets+ /// `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, so this enum would otherwise+ /// be implicitly `@MainActor` and its synthesised `Equatable` conformance+ /// main-actor-isolated — while the Swift-Testing suites that compare+ /// branches are nonisolated. That combination compiles today only because+ /// the test target is still on `SWIFT_VERSION = 5.0`; `nonisolated` states+ /// the intent instead of relying on it. The enum is a plain value with no+ /// isolated state to protect.+ nonisolated enum Branch: Equatable {+ /// Req 5.1: the library has nothing in it, whatever is typed.+ case emptyLibrary+ /// Req 5.2: a query with nothing behind it.+ case searchMiss+ case list+ }++ let branch: Branch+ /// The rows to render, scope-selected, banner-filtered and searched.+ let groups: [RecentPresentationGroup]+ /// The duplicate plan's summary lines, built here so the view does not+ /// construct a second `RecentDuplicatePlan` of its own — and empty unless+ /// the duplicate filter is active, because the question they answer ("what+ /// did the banner count that Recent holds no row for") is only asked while+ /// that banner is filtering.+ let elsewhere: [RecentDuplicatePlan.Elsewhere]+ /// Req 3.1's disclosure that older entries live in Works.+ let showsFooter: Bool++ /// Takes `conflictCount` and *produces* `elsewhere` rather than taking a+ /// pre-computed flag: the view would otherwise build `RecentDuplicatePlan`+ /// twice per evaluation, and an input flag admits the contradictory pair+ /// `showsElsewhereSection: true` with `showingDuplicatesOnly: false`.+ init(+ presentation: RecentPresentation,+ showingActionableOnly: Bool,+ showingDuplicatesOnly: Bool,+ searchFilter: RecentSearchFilter,+ conflictCount: Int,+ hasWorksRoute: Bool,+ limit: Int = RecentRowCap.rowLimit+ ) {+ // Short-circuited, not built and discarded: the plan is O(review items)+ // and this initialiser runs on every `body` evaluation — once per+ // keystroke in the search field. The lines are only ever read while the+ // duplicate filter is on.+ let elsewhere =+ showingDuplicatesOnly+ ? RecentDuplicatePlan(+ workload: presentation.duplicateWorkload, conflictCount: conflictCount+ ).elsewhere+ : []+ self.elsewhere = elsewhere++ // The scope rule, covering Reqs 2.3, 2.4, 2.5, 4.1, 4.2 and 4.3 in one+ // condition: the cap applies only to the plain list. A banner filter and+ // a query each see the whole library, so every counted row stays+ // reachable and note text stays findable however old it is.+ let isBounded =+ !showingActionableOnly && !showingDuplicatesOnly && !searchFilter.isActive+ let scoped: [RecentPresentationGroup]+ let excludedRowCount: Int+ if isBounded {+ let capped = RecentRowCap.apply(to: presentation.groups, limit: limit)+ scoped = capped.groups+ excludedRowCount = capped.excludedRowCount+ } else {+ scoped = presentation.groups+ excludedRowCount = 0+ }++ // Req 9.1's duplicate filter is its own filter rather than a widening of+ // the actionable one: a row can be in a review set without needing+ // teaching, and folding the two together would make the actionable count+ // and the rows it filters to disagree. With both on, the two are a union.+ let bannerFiltered: [RecentPresentationGroup]+ if showingActionableOnly || showingDuplicatesOnly {+ bannerFiltered = scoped.compactMap { group in+ let filtered = group.rows.filter { row in+ (showingActionableOnly && row.isActionable)+ || (showingDuplicatesOnly && row.duplicateRoute != nil)+ }+ guard !filtered.isEmpty else { return nil }+ return RecentPresentationGroup(day: group.day, rows: filtered)+ }+ } else {+ bannerFiltered = scoped+ }++ // Req 3.2: search runs **after** the banner filters, so both conditions+ // apply. Never before — the unfiltered set would surface a row the+ // banner had deliberately excluded.+ let searched = searchFilter.apply(to: bannerFiltered)+ groups = searched++ // The filtered state keeps the list even where the library has no rows+ // to show in it: what the duplicate banner counted may live entirely+ // outside Recent, and the Elsewhere lines are the only route to it.+ if presentation.groups.isEmpty && !showingDuplicatesOnly {+ branch = .emptyLibrary+ } else if searchFilter.isActive && searched.isEmpty && elsewhere.isEmpty {+ branch = .searchMiss+ } else {+ branch = .list+ }++ // The route term matches the convention every other optional route in+ // `RecentView` follows, so the footer is never rendered dead. The count+ // is the one the same `RecentRowCap.apply` call produced.+ showsFooter =+ branch == .list && isBounded && excludedRowCount > 0 && hasWorksRoute+ }+}+ /// What the duplicate banner counts, split into what Recent can show and what it /// cannot (Req 9.1). ///
diff --git a/Asterism/Asterism/ViewModels/RecentRowCap.swift b/Asterism/Asterism/ViewModels/RecentRowCap.swiftnew file mode 100644index 0000000..6beb985--- /dev/null+++ b/Asterism/Asterism/ViewModels/RecentRowCap.swift@@ -0,0 +1,63 @@+import AsterismCore+import Foundation++/// Req 1.1's cap on the Recent list: the newest 100 logical rows, with no+/// age-based exclusion (Decision 2).+///+/// A presentation boundary, not a derivation filter (Decision 1). `groups` keeps+/// its meaning — every logical row, ordered, day-grouped — so the library-wide+/// counts, the `allRows` teaching lookups, the banner filters and full-library+/// search all keep reading what they read today. Nothing in `AsterismCore`+/// changes; this is presentation logic over a published DTO, which is exactly+/// what `SearchFilters.swift` beside it already is (Q29).+///+/// Rows arrive ordered `lastSharedAt` descending, so the newest `limit` are a+/// plain prefix of the flattened set. The only real work is mapping that prefix+/// back onto day groups: whole groups, plus at most one truncated group at the+/// tail.+enum RecentRowCap {+ /// The fixed cap (Q3). Not user-configurable, and not a stored property on+ /// the DTO — a second copy of this number is a number that can disagree.+ static let rowLimit = 100++ /// The newest `limit` rows with day grouping preserved — whole groups plus+ /// at most one truncated group — and how many rows fall beyond them.+ ///+ /// One function returning both values rather than two each taking a limit:+ /// two independently defaulted limits could be called with different values+ /// and nothing would catch it (Q30). It also walks the groups once.+ ///+ /// The total is accumulated in the same walk that builds the capped groups,+ /// never from `allRows.count` — `allRows` is `groups.flatMap(\.rows)`, which+ /// would allocate the whole library on every `body` evaluation.+ ///+ /// `limit` must be positive; production always passes `rowLimit`.+ static func apply(+ to groups: [RecentPresentationGroup], limit: Int = rowLimit+ ) -> (groups: [RecentPresentationGroup], excludedRowCount: Int) {+ precondition(limit > 0, "The Recent cap must keep at least one row")++ var capped: [RecentPresentationGroup] = []+ capped.reserveCapacity(groups.count)+ var total = 0+ var remaining = limit++ for group in groups {+ total += group.rows.count+ guard remaining > 0 else { continue }+ if group.rows.count <= remaining {+ capped.append(group)+ remaining -= group.rows.count+ } else {+ // Req 1.2 accepts a partially shown oldest day group: the rows+ // are already in order, so the kept ones are its prefix.+ capped.append(+ RecentPresentationGroup(+ day: group.day, rows: Array(group.rows.prefix(remaining))))+ remaining = 0+ }+ }++ return (capped, max(0, total - limit))+ }+}
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex fb1fb60..4b6eab6 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -21,6 +21,17 @@ struct ContentView: View { /// and Back landed on the Works list. @State private var selectedWorkChapterEntryID: UUID? @State private var selectedWorkID: UUID?+ /// Req 3.2: the Works query is `@State` inside `WorksView` and survives a+ /// tab switch, so the footer's route would otherwise land the reader on a+ /// filtered list that also hides Unattached Notes — breaking Req 1.3 for the+ /// very rows it sent them to find. Bumping this token rebuilds `WorksView`+ /// with its query back at `""`.+ ///+ /// A token rather than a `Binding` hoisted into this view (Q22): a hoisted+ /// query would rebuild `ContentView`'s body, and `RecentView` with it, on+ /// every keystroke typed into the Works search field. The token costs one+ /// invalidation per footer tap and touches `WorksView`'s API not at all.+ @State private var worksResetToken = 0 @State private var showingNewWork = false @State private var showingMoveTo: UUID? @State private var showingSettings = false@@ -203,7 +214,8 @@ struct ContentView: View { // sheet the toolbar opens, where the condition and its // remedy are spelled out. onShowSyncSettings: { showingSettings = true },- onResolveDuplicate: route(toResolve:)+ onResolveDuplicate: route(toResolve:),+ onShowWorks: showWorksRoot ) .navigationTitle("Recent") .toolbar {@@ -248,6 +260,14 @@ struct ContentView: View { onNewWork: { showingNewWork = true }, onResolveDuplicate: route(toResolve:) )+ // Req 3.2: the footer's route bumps this, which gives+ // `WorksView` a new identity — so it is rebuilt from+ // scratch with its `@State` query back at "". Rebuilding+ // the whole subtree also discards its scroll position,+ // which is the cost and, for a route whose promise is "the+ // Works root", arguably the point. Nothing else bumps the+ // token, so no ordinary update pays it.+ .id(worksResetToken) .navigationTitle("Works") .navigationDestination(item: $selectedWorkID) { workID in if let detailModel = model.workDetailModel(for: workID) {@@ -424,6 +444,25 @@ struct ContentView: View { route(toResolve: setKey) } + /// Req 3.2's route out of Recent's truncation footer: the Works section at+ /// its root, with no search filter applied.+ ///+ /// The three stack destinations are cleared explicitly rather than left to+ /// cascade on the next update — the reader asked to be at the root, and a+ /// push that unwinds one turn later is not that.+ ///+ /// `selectedWorkChapterEntryID` is therefore cleared here *and* by the+ /// `onChange(of: selectedWorkID)` above, deliberately: the `onChange` fires+ /// on a later update, and only when `selectedWorkID` actually changed, so it+ /// is the safety net for the ordinary case rather than this route's clear.+ private func showWorksRoot() {+ selectedTab = .works+ selectedWorksEntryID = nil+ selectedWorkChapterEntryID = nil+ selectedWorkID = nil+ worksResetToken += 1+ }+ /// Q30's fork, in the one place that owns navigation: a divergent Work set /// with no torn member is a *Merge*, which is the Works flow, and everything /// else is the resolution sheet.
diff --git a/Asterism/AsterismTests/RecentDisplayPlanTests.swift b/Asterism/AsterismTests/RecentDisplayPlanTests.swiftnew file mode 100644index 0000000..59e6eda--- /dev/null+++ b/Asterism/AsterismTests/RecentDisplayPlanTests.swift@@ -0,0 +1,345 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++// What the Recent screen renders: which branch, which rows, which summary lines,+// and whether the truncation footer appears. Extracted from `body` because no+// app-layer test can assert which branch a `some View` took (Q26).+//+// Every case passes a small `limit`, so a footer-present case needs three rows+// rather than a hundred and one.+@Suite("Recent display plan")+struct RecentDisplayPlanTests {++ // MARK: - Fixtures++ private var day: Date { TestFixtures.fixedDate }+ private var earlierDay: Date { TestFixtures.earlierDate }++ private func group(_ day: Date, _ rows: [RecentPresentationRow]) -> RecentPresentationGroup {+ RecentPresentationGroup(day: day, rows: rows)+ }++ private func presentation(+ _ groups: [RecentPresentationGroup], workload: DuplicateWorkload = .empty+ ) -> RecentPresentation {+ RecentPresentation(+ groups: groups, actionableCount: groups.flatMap(\.rows).filter(\.isActionable).count,+ duplicateWorkload: workload)+ }++ /// `limit: 2` everywhere it is not stated: the cap's own regrouping is+ /// `RecentRowCapTests`' subject, and here it only needs to bind.+ private func makePlan(+ _ presentation: RecentPresentation,+ actionable: Bool = false,+ duplicates: Bool = false,+ query: String = "",+ conflictCount: Int = 0,+ hasWorksRoute: Bool = true,+ limit: Int = 2+ ) -> RecentDisplayPlan {+ RecentDisplayPlan(+ presentation: presentation,+ showingActionableOnly: actionable,+ showingDuplicatesOnly: duplicates,+ searchFilter: RecentSearchFilter(query: query),+ conflictCount: conflictCount,+ hasWorksRoute: hasWorksRoute,+ limit: limit)+ }++ private func rowIDs(_ plan: RecentDisplayPlan) -> [UUID] {+ plan.groups.flatMap(\.rows).map(\.id)+ }++ // MARK: - The capped view (1.1, 3.1)++ @Test("With no filter and no query the list is the capped prefix and the footer appears")+ func unfilteredListIsCapped() {+ let first = TestFixtures.makeRecentRow(captureTitle: "a")+ let second = TestFixtures.makeRecentRow(captureTitle: "b")+ let third = TestFixtures.makeRecentRow(captureTitle: "c")+ let plan = makePlan(presentation([group(day, [first, second, third])]))++ #expect(plan.branch == .list)+ #expect(rowIDs(plan) == [first.id, second.id])+ #expect(plan.showsFooter)+ }++ /// The one case that pins the *production* default. Every other case here+ /// passes `limit: 2`, so all of them would stay green if the default moved+ /// off 100 — leaving Req 1.1's claim about the screen unenforced. Built+ /// without a `limit` argument for exactly that reason, with the rows+ /// generated rather than written out.+ @Test("Without a stated limit the plan caps the screen at 100 rows")+ func productionDefaultCapsAtOneHundred() {+ let rows = (0..<101).map { TestFixtures.makeRecentRow(captureTitle: "row \($0)") }+ let plan = RecentDisplayPlan(+ presentation: presentation([group(day, rows)]),+ showingActionableOnly: false,+ showingDuplicatesOnly: false,+ searchFilter: RecentSearchFilter(query: ""),+ conflictCount: 0,+ hasWorksRoute: true)++ #expect(plan.branch == .list)+ #expect(rowIDs(plan) == rows.prefix(100).map(\.id))+ #expect(plan.showsFooter)+ }++ /// Req 3.3's first half: nothing beyond the cap, nothing to disclose.+ @Test("A library inside the cap shows no footer")+ func libraryInsideTheCapShowsNoFooter() {+ let plan = makePlan(+ presentation([+ group(day, [TestFixtures.makeRecentRow(), TestFixtures.makeRecentRow()])+ ]))++ #expect(plan.branch == .list)+ #expect(rowIDs(plan).count == 2)+ #expect(!plan.showsFooter)+ }++ /// Req 3.1's route term: the footer is never rendered dead, matching the+ /// convention every other optional route in `RecentView` follows.+ @Test("Without a Works route the footer is suppressed even when rows are excluded")+ func noWorksRouteSuppressesTheFooter() {+ let plan = makePlan(+ presentation([+ group(day, [+ TestFixtures.makeRecentRow(), TestFixtures.makeRecentRow(),+ TestFixtures.makeRecentRow(),+ ])+ ]),+ hasWorksRoute: false)++ #expect(plan.branch == .list)+ #expect(!plan.showsFooter)+ }++ // MARK: - Banner filters bypass the cap (2.3, 2.4, 2.5)++ @Test("An active actionable filter shows every match regardless of the cap")+ func actionableFilterBypassesTheCap() {+ let rows = (0..<3).map {+ TestFixtures.makeRecentRow(captureTitle: "a\($0)", isActionable: true)+ }+ let plan = makePlan(presentation([group(day, rows)]), actionable: true)++ #expect(rowIDs(plan) == rows.map(\.id))+ #expect(!plan.showsFooter, "Req 3.3: a filtered list discloses nothing about the cap")+ }++ @Test("An active duplicate filter shows every match regardless of the cap")+ func duplicateFilterBypassesTheCap() {+ let rows = (0..<3).map {+ TestFixtures.makeRecentRow(captureTitle: "d\($0)", duplicateRoute: .sheet)+ }+ let plan = makePlan(presentation([group(day, rows)]), duplicates: true)++ #expect(rowIDs(plan) == rows.map(\.id))+ #expect(!plan.showsFooter)+ }++ /// Req 2.4: the two filters compose as a union, exactly as they do today — a+ /// row needs to satisfy either one, not both.+ @Test("Both banner filters together show the union of their matches")+ func bothFiltersShowTheirUnion() {+ let actionable = TestFixtures.makeRecentRow(captureTitle: "a", isActionable: true)+ let duplicated = TestFixtures.makeRecentRow(captureTitle: "d", duplicateRoute: .sheet)+ let settled = TestFixtures.makeRecentRow(captureTitle: "s")+ let plan = makePlan(+ presentation([group(day, [actionable, duplicated, settled])]),+ actionable: true, duplicates: true)++ #expect(rowIDs(plan) == [actionable.id, duplicated.id])+ }++ /// Req 2.3's "same day grouping" clause. Every other banner-filter case in+ /// this suite sits in a single day group, so a regression that flattened the+ /// groups while filtering — one group holding every match — would pass all+ /// of them. This one spans two days and keeps the excluded row in the first,+ /// so the grouping is asserted rather than inherited.+ @Test("A banner filter keeps its day groups across more than one day")+ func bannerFilterPreservesDayGroups() {+ let first = TestFixtures.makeRecentRow(captureTitle: "a", isActionable: true)+ let settled = TestFixtures.makeRecentRow(captureTitle: "s")+ let second = TestFixtures.makeRecentRow(captureTitle: "b", isActionable: true)+ let earlier = TestFixtures.makeRecentRow(captureTitle: "c", isActionable: true)+ let plan = makePlan(+ presentation([+ group(day, [first, settled, second]),+ group(earlierDay, [earlier]),+ ]),+ actionable: true)++ #expect(plan.branch == .list)+ #expect(plan.groups.map(\.day) == [day, earlierDay])+ #expect(plan.groups[0].rows.map(\.id) == [first.id, second.id])+ #expect(plan.groups[1].rows.map(\.id) == [earlier.id])+ }++ /// Req 2.5: with the last filter cleared and nothing else active, the cap is+ /// back.+ @Test("Clearing the banner filters returns the capped view")+ func clearingTheFiltersReturnsTheCappedView() {+ let rows = (0..<3).map {+ TestFixtures.makeRecentRow(captureTitle: "a\($0)", isActionable: true)+ }+ let library = presentation([group(day, rows)])++ #expect(rowIDs(makePlan(library, actionable: true)).count == 3)+ #expect(rowIDs(makePlan(library)).count == 2)+ #expect(makePlan(library).showsFooter)+ }++ // MARK: - Search covers the full library (4.1, 4.2, 4.3)++ @Test("An active query matches the full library regardless of the cap")+ func searchBypassesTheCap() {+ let rows = (0..<3).map { TestFixtures.makeRecentRow(captureTitle: "Bees \($0)") }+ let plan = makePlan(presentation([group(day, rows)]), query: "bees")++ #expect(rowIDs(plan) == rows.map(\.id))+ #expect(!plan.showsFooter, "Req 3.3: a searched list discloses nothing about the cap")+ }++ @Test("Clearing the query returns the capped view")+ func clearingTheQueryReturnsTheCappedView() {+ let rows = (0..<3).map { TestFixtures.makeRecentRow(captureTitle: "Bees \($0)") }+ let library = presentation([group(day, rows)])++ #expect(rowIDs(makePlan(library, query: "bees")).count == 3)+ #expect(rowIDs(makePlan(library)).count == 2)+ }++ /// Req 4.3: the banner filter runs first and the query narrows within it, so+ /// a row the banner excluded is never resurrected by matching the query.+ @Test("A banner filter and a query compose, both uncapped")+ func filterAndQueryCompose() {+ let hit = TestFixtures.makeRecentRow(captureTitle: "Bees one", isActionable: true)+ let secondHit = TestFixtures.makeRecentRow(captureTitle: "Bees two", isActionable: true)+ let thirdHit = TestFixtures.makeRecentRow(captureTitle: "Bees three", isActionable: true)+ let settled = TestFixtures.makeRecentRow(captureTitle: "Bees settled")+ let plan = makePlan(+ presentation([group(day, [hit, secondHit, thirdHit, settled])]),+ actionable: true, query: "bees")++ #expect(rowIDs(plan) == [hit.id, secondHit.id, thirdHit.id])+ }++ // MARK: - Day grouping under the cap (1.2)++ @Test("The capped list keeps its day groups, truncating only the oldest included one")+ func cappedListKeepsDayGroups() {+ let first = TestFixtures.makeRecentRow(captureTitle: "a")+ let second = TestFixtures.makeRecentRow(captureTitle: "b")+ let third = TestFixtures.makeRecentRow(captureTitle: "c")+ let plan = makePlan(+ presentation([group(day, [first]), group(earlierDay, [second, third])]))++ #expect(plan.groups.map(\.day) == [day, earlierDay])+ #expect(plan.groups[1].rows.map(\.id) == [second.id])+ }++ // MARK: - Empty states (5.1, 5.2, 5.3)++ @Test("An empty library takes the empty-library branch")+ func emptyLibraryBranch() {+ let plan = makePlan(presentation([]))++ #expect(plan.branch == .emptyLibrary)+ #expect(plan.groups.isEmpty)+ #expect(!plan.showsFooter)+ }++ /// Req 5.1: "whatever is typed in the search field". A reader with nothing+ /// captured is told that, rather than that their query found nothing.+ @Test("The empty-library branch survives an active query")+ func emptyLibraryOutranksTheQuery() {+ #expect(makePlan(presentation([]), query: "zzz").branch == .emptyLibrary)+ }++ /// Req 5.1's stated exception, and it is reachable: the duplicate banner's+ /// count includes preserved edits, which have no rows at all, so an empty+ /// library can still carry the filter — at which point the Elsewhere lines+ /// are the only route to those edits and an empty state would strand them.+ ///+ /// `@MainActor`, as the duplicate suites are: comparing an+ /// `Elsewhere.Route` reaches a main-actor-isolated conformance.+ @Test("The duplicate filter over an empty library keeps the list")+ @MainActor func duplicateFilterOverAnEmptyLibraryKeepsTheList() {+ let plan = makePlan(presentation([]), duplicates: true, conflictCount: 1)++ #expect(plan.branch == .list)+ #expect(plan.groups.isEmpty)+ #expect(plan.elsewhere.count == 1)+ #expect(plan.elsewhere.first?.route == .checkLibrary)+ }++ /// The actionable filter carries no such exception.+ @Test("The actionable filter over an empty library keeps the empty-library state")+ func actionableFilterOverAnEmptyLibraryKeepsTheEmptyState() {+ #expect(makePlan(presentation([]), actionable: true).branch == .emptyLibrary)+ }++ @Test("A query with no matches over a non-empty library takes the search-miss branch")+ func searchMissBranch() {+ let plan = makePlan(+ presentation([group(day, [TestFixtures.makeRecentRow(captureTitle: "Bees")])]),+ query: "zzqqxx")++ #expect(plan.branch == .searchMiss)+ #expect(plan.groups.isEmpty)+ #expect(!plan.showsFooter)+ }++ /// Req 5.2's exception and Req 5.3's invariant in one: the Elsewhere lines+ /// carry no titles to match, so a query never hides them — and replacing the+ /// list with an empty state would leave a counted item as neither a row nor+ /// a line.+ @Test("A no-match query keeps the list while Elsewhere lines are present")+ func searchMissYieldsToElsewhereLines() {+ let plan = makePlan(+ presentation([group(day, [TestFixtures.makeRecentRow(captureTitle: "Bees")])]),+ duplicates: true, query: "zzqqxx", conflictCount: 1)++ #expect(plan.branch == .list)+ #expect(plan.groups.isEmpty)+ #expect(plan.elsewhere.count == 1)+ }++ /// The Elsewhere section belongs to the duplicate filter alone: its lines+ /// answer "what did the banner count that Recent holds no row for", which is+ /// only a question while that banner is filtering.+ @Test("Elsewhere lines are absent unless the duplicate filter is active")+ func elsewhereRequiresTheDuplicateFilter() {+ let library = presentation([group(day, [TestFixtures.makeRecentRow()])])++ #expect(makePlan(library, conflictCount: 2).elsewhere.isEmpty)+ #expect(makePlan(library, actionable: true, conflictCount: 2).elsewhere.isEmpty)+ #expect(makePlan(library, duplicates: true, conflictCount: 2).elsewhere.count == 1)+ }++ /// A Work set is counted by the banner and has no Recent row, so it is named+ /// in the section rather than filtered into a blank screen.+ @Test("A Work review set is named in the Elsewhere lines")+ @MainActor func workSetsAreNamedElsewhere() {+ let workSet = DuplicateSetKey(recordType: .work, memberIDs: [UUID(), UUID()])+ let workload = DuplicateWorkload(+ reviewItems: [+ DuplicateReviewItem(+ key: workSet, route: .merge, memberIDs: workSet.memberIDs,+ variantCount: 2, isTorn: false)+ ],+ deferredItems: [])+ let plan = makePlan(+ presentation([group(day, [TestFixtures.makeRecentRow()])], workload: workload),+ duplicates: true)++ #expect(plan.branch == .list)+ #expect(plan.elsewhere.map(\.route) == [.resolve(workSet)])+ }+}
diff --git a/Asterism/AsterismTests/RecentRowCapTests.swift b/Asterism/AsterismTests/RecentRowCapTests.swiftnew file mode 100644index 0000000..2ff5625--- /dev/null+++ b/Asterism/AsterismTests/RecentRowCapTests.swift@@ -0,0 +1,153 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++// Req 1.1's cap over the already-ordered, already-day-grouped rows. Rows are+// ordered `lastSharedAt` descending, so the newest `limit` are a plain prefix+// (Decision 1) — the only real work is mapping that prefix back onto day+// groups, and that is what these cases enumerate.+//+// Every case drives a small `limit` rather than the production 100: the+// regrouping rules are the same at three rows as at a hundred, and a 101-row+// fixture would state them less clearly.+@Suite("Recent row cap")+struct RecentRowCapTests {++ private func group(_ day: Date, _ rows: [RecentPresentationRow]) -> RecentPresentationGroup {+ RecentPresentationGroup(day: day, rows: rows)+ }++ private func row(_ title: String) -> RecentPresentationRow {+ TestFixtures.makeRecentRow(captureTitle: title)+ }++ /// Three descending day stamps, newest first — the order the rows arrive in.+ private var monday: Date { TestFixtures.laterDate }+ private var sunday: Date { TestFixtures.fixedDate }+ private var saturday: Date { TestFixtures.earlierDate }++ // MARK: - Nothing to exclude (1.1, 3.1)++ @Test("A limit above the row count returns the groups unchanged with nothing excluded")+ func limitAboveTotalChangesNothing() {+ let groups = [+ group(monday, [row("a"), row("b")]),+ group(sunday, [row("c")]),+ ]+ let result = RecentRowCap.apply(to: groups, limit: 10)+ #expect(result.groups == groups)+ #expect(result.excludedRowCount == 0)+ }++ /// Req 1.1's second clause, stated positively: the cap excludes by count+ /// alone. Rows a decade old survive inside the limit, so a future age+ /// cutoff would have to fail this rather than merely be absent from the+ /// type. Decision 2 dropped the 14-day window precisely because it would+ /// have emptied Recent for a reader returning from a break.+ @Test("Age never excludes a row: decade-old rows inside the limit are kept")+ func ageAloneNeverExcludes() {+ let decadeAgo = Date(timeIntervalSince1970: 1_750_000_000 - 315_360_000)+ let evenOlder = decadeAgo.addingTimeInterval(-315_360_000)+ let groups = [+ group(decadeAgo, [row("old a"), row("old b")]),+ group(evenOlder, [row("older")]),+ ]+ let result = RecentRowCap.apply(to: groups, limit: 10)+ #expect(result.groups == groups)+ #expect(result.excludedRowCount == 0)+ }++ @Test("A limit exactly equal to the row count excludes nothing")+ func limitEqualToTotalExcludesNothing() {+ let groups = [+ group(monday, [row("a"), row("b")]),+ group(sunday, [row("c")]),+ ]+ let result = RecentRowCap.apply(to: groups, limit: 3)+ #expect(result.groups == groups)+ #expect(result.excludedRowCount == 0)+ }++ @Test("An empty list of groups stays empty")+ func emptyGroupsStayEmpty() {+ let result = RecentRowCap.apply(to: [], limit: 3)+ #expect(result.groups.isEmpty)+ #expect(result.excludedRowCount == 0)+ }++ // MARK: - The cap on a day-group boundary (1.2)++ /// The prefix ends where a group ends, so the groups beyond it go whole —+ /// no empty day header is left behind.+ @Test("A cap on a group boundary drops the trailing groups whole")+ func capOnGroupBoundaryDropsTrailingGroups() {+ let kept = [row("a"), row("b")]+ let groups = [+ group(monday, kept),+ group(sunday, [row("c")]),+ group(saturday, [row("d")]),+ ]+ let result = RecentRowCap.apply(to: groups, limit: 2)+ #expect(result.groups == [group(monday, kept)])+ #expect(result.excludedRowCount == 2)+ }++ // MARK: - The cap inside a day group (1.2)++ /// Req 1.2 accepts a partially shown oldest day group: the truncated group+ /// keeps its day and its leading rows in order, the groups before it are+ /// untouched, and the ones after it are gone.+ @Test("A cap inside a group truncates it, keeps earlier groups, drops later ones")+ func capInsideAGroupTruncatesIt() {+ let first = row("a")+ let second = row("b")+ let third = row("c")+ let groups = [+ group(monday, [first]),+ group(sunday, [second, third, row("d")]),+ group(saturday, [row("e")]),+ ]+ let result = RecentRowCap.apply(to: groups, limit: 3)++ #expect(result.groups.map(\.day) == [monday, sunday])+ #expect(result.groups[0].rows.map(\.id) == [first.id])+ // Order inside the truncated group is preserved — it is a prefix of it.+ #expect(result.groups[1].rows.map(\.id) == [second.id, third.id])+ #expect(result.excludedRowCount == 2)+ }++ // MARK: - The excluded count (3.1)++ @Test("The excluded count is the total minus the limit")+ func excludedCountIsTotalMinusLimit() {+ let groups = [+ group(monday, [row("a"), row("b"), row("c")]),+ group(sunday, [row("d"), row("e")]),+ ]+ #expect(RecentRowCap.apply(to: groups, limit: 1).excludedRowCount == 4)+ #expect(RecentRowCap.apply(to: groups, limit: 4).excludedRowCount == 1)+ #expect(RecentRowCap.apply(to: groups, limit: 5).excludedRowCount == 0)+ }++ @Test("A limit of one keeps exactly the newest row")+ func limitOfOneKeepsTheNewestRow() {+ let newest = row("a")+ let groups = [+ group(monday, [newest, row("b")]),+ group(sunday, [row("c")]),+ ]+ let result = RecentRowCap.apply(to: groups, limit: 1)+ #expect(result.groups.count == 1)+ #expect(result.groups[0].day == monday)+ #expect(result.groups[0].rows.map(\.id) == [newest.id])+ #expect(result.excludedRowCount == 2)+ }++ // MARK: - The production limit++ @Test("The production limit is 100 rows")+ func productionLimitIsOneHundred() {+ #expect(RecentRowCap.rowLimit == 100)+ }+}
diff --git a/Asterism/AsterismUITests/M4ScaleRecentPerformanceUITests.swift b/Asterism/AsterismUITests/M4ScaleRecentPerformanceUITests.swiftindex 74f25de..e168e5e 100644--- a/Asterism/AsterismUITests/M4ScaleRecentPerformanceUITests.swift+++ b/Asterism/AsterismUITests/M4ScaleRecentPerformanceUITests.swift@@ -32,6 +32,11 @@ final class M4ScaleRecentPerformanceUITests: XCTestCase { /// Proves the scenario seeds and the app reaches Recent on it. Runs /// everywhere, including the simulator.+ ///+ /// Deliberately nothing else: this is the seeding guard, and a journey+ /// riding on it would report a journey flake as a seeder that threw. The+ /// truncation-footer journey is `testTruncationFooterOpensWorksRoot` below,+ /// on the same fixture. @MainActor func testSeededScaleM4ScenarioReachesRecent() throws { let app = launchFreshScaleFixture(scenario: Self.coherentScenario)@@ -42,6 +47,80 @@ final class M4ScaleRecentPerformanceUITests: XCTestCase { app.terminate() } + /// Req 3.2's navigation, on this suite's fixture because it is the only one+ /// with a library large enough for the cap to bind: 5,000 Entries means+ /// 4,900 rows beyond the newest 100, so the truncation footer is on screen+ /// and its route can be exercised. The assertion is built so it can fail —+ /// see `dirtyTheWorksState`.+ @MainActor+ func testTruncationFooterOpensWorksRoot() throws {+ let app = launchFreshScaleFixture(scenario: Self.coherentScenario)+ waitFor(+ app.collectionViews["recent-list"], "The seeded scale library opens on Recent",+ timeout: Self.seedTimeout)++ dirtyTheWorksState(in: app)+ waitFor(app.tabBars.buttons["Recent"], "The Recent tab is reachable").tap()++ // Req 3.1's footer sits below the capped 100 rows, and the fixture+ // stamps every Entry `timeIntervalSince1970: entryIndex` — all within+ // 1970 — so the whole list is one day group with no headers to break+ // the fall. Hence the raised attempt count and the list's own fast+ // swipe rather than the helper's default eight `app.swipeUp()`s.+ //+ // A typed query, not `anyElement`: `.any` forces a full accessibility+ // snapshot on every `exists` check, and this loop makes dozens.+ let footer = app.buttons["recent-truncation-footer"]+ let list = app.collectionViews["recent-list"]+ scrollUntilTappableAndTap(+ footer, in: app,+ "Req 3.1: a library of 5,000 entries discloses that older ones are in Works",+ attempts: 40, scroll: { _ in list.swipeUp(velocity: .fast) })++ // Req 3.2: the Works section's root, with no search filter applied.+ waitFor(app.collectionViews["works-list"], "The footer opens the Works list")+ XCTAssertFalse(+ app.anyElement("work-detail-title").exists,+ "Req 3.2 asks for the Works root, not the work detail left on the stack")+ // An empty field reports its placeholder as its value; the typed query+ // would report itself. This *is* the Unattached Notes assertion Req 1.3+ // needs: the group is hidden precisely while a query is active+ // (`WorksView:105`), and it sits below all 1,000 seeded work rows, so a+ // lazy `List` never puts its header in the accessibility tree here.+ XCTAssertEqual(+ searchField(in: app).value as? String, "Search works",+ """+ Req 3.2/1.3: Works opens with no query, which is what keeps \+ Unattached Notes listed for the rows the footer sent the reader to find+ """)++ app.terminate()+ }++ /// Puts the Works tab into the state Req 3.2 has to undo: a query typed into+ /// its `@State` search field, and a work detail pushed onto its stack.+ ///+ /// Without this the assertion above would pass identically against code that+ /// only switched tabs — on a fresh launch the query is already empty and the+ /// stack is already at its root.+ @MainActor+ private func dirtyTheWorksState(in app: XCUIApplication) {+ waitFor(app.tabBars.buttons["Works"], "The Works tab is reachable").tap()+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library", timeout: 60)++ let field = searchField(in: app)+ field.tap()+ // The fixture's works are `Story 0`…`Story 999`, so this narrows the+ // list without emptying it.+ field.typeText("Story 1")+ XCTAssertEqual(+ field.value as? String, "Story 1", "The Works query is what the footer has to clear")++ let firstWork = app.elements(withIdentifierPrefix: "work-row-").firstMatch+ scrollUntilTappableAndTap(firstWork, in: app, "A matching work is listed under the query")+ waitFor(app.anyElement("work-detail-title"), "The work detail is pushed onto the stack")+ }+ @MainActor func testRecentPublicationSignpostAtM4ComposedScale() throws { try requirePhysicalMeasurementEnvironment()
diff --git a/Asterism/AsterismUITests/UIJourneySupport.swift b/Asterism/AsterismUITests/UIJourneySupport.swiftindex 8e03273..85e0d67 100644--- a/Asterism/AsterismUITests/UIJourneySupport.swift+++ b/Asterism/AsterismUITests/UIJourneySupport.swift@@ -178,19 +178,44 @@ extension XCTestCase { /// Scrolls until the element is hittable, then taps it. Lazy `List`/`Form` /// rows below the fold are not in the accessibility tree until scrolled in, /// so existence is not a precondition for scrolling.+ ///+ /// `attempts` covers a target further down than eight screenfuls (the+ /// truncation footer under 100 capped rows is one), and `scroll` covers a+ /// screen where `app.swipeUp()` is the wrong gesture — the same reason+ /// `expandDisclosure` above takes one. Both default to what every existing+ /// caller was already getting. func scrollUntilTappableAndTap( _ element: XCUIElement, in app: XCUIApplication, _ message: String,+ attempts: Int = 8, scroll: (XCUIApplication) -> Void = { $0.swipeUp() }, file: StaticString = #filePath, line: UInt = #line ) { _ = element.waitForExistence(timeout: 15)- for _ in 0..<8 {+ for _ in 0..<attempts { if element.exists, element.isHittable { element.tap() return }- app.swipeUp()+ scroll(app) _ = element.waitForExistence(timeout: 2) } XCTFail(message, file: file, line: line) }++ /// The navigation bar's search field.+ ///+ /// Two things make a bare `app.searchFields.firstMatch` unreliable: iOS may+ /// present the field collapsed until the list is pulled down, and in a+ /// scrolled list the field **exists without being hittable**, so a tap on it+ /// silently misses. Both are answered by pulling the list down, which is why+ /// the two guards live together here rather than one at each call site.+ @discardableResult+ func searchField(+ in app: XCUIApplication, file: StaticString = #filePath, line: UInt = #line+ ) -> XCUIElement {+ let field = app.searchFields.firstMatch+ if !field.waitForExistence(timeout: 5) { app.swipeDown() }+ let resolved = waitFor(field, "The tab offers a search field", file: file, line: line)+ if !resolved.isHittable { app.swipeDown() }+ return resolved+ } }
diff --git a/Asterism/AsterismUITests/SearchUITests.swift b/Asterism/AsterismUITests/SearchUITests.swiftindex e14a1c5..9624b44 100644--- a/Asterism/AsterismUITests/SearchUITests.swift+++ b/Asterism/AsterismUITests/SearchUITests.swift@@ -39,22 +39,11 @@ final class SearchUITests: XCTestCase { app.elements(withIdentifierPrefix: "work-row-") } - /// The search field lives in the navigation bar, which iOS may present- /// collapsed until the list is pulled down.- @discardableResult- private func searchField(- file: StaticString = #filePath, line: UInt = #line- ) -> XCUIElement {- let field = app.searchFields.firstMatch- if !field.waitForExistence(timeout: 5) {- app.swipeDown()- }- return waitFor(field, "The tab offers a search field", file: file, line: line)- }-+ /// `searchField(in:)` is shared with the other journey suites+ /// (`UIJourneySupport`), and carries the collapsed-field and+ /// not-yet-hittable guards this suite used to apply here. private func type(_ query: String, file: StaticString = #filePath, line: UInt = #line) {- let field = searchField(file: file, line: line)- if !field.isHittable { app.swipeDown() }+ let field = searchField(in: app, file: file, line: line) field.tap() field.typeText(query) }
diff --git a/Asterism/AsterismTests/Helpers/TestFixtures.swift b/Asterism/AsterismTests/Helpers/TestFixtures.swiftindex a6525d6..5c245d9 100644--- a/Asterism/AsterismTests/Helpers/TestFixtures.swift+++ b/Asterism/AsterismTests/Helpers/TestFixtures.swift@@ -58,7 +58,10 @@ enum TestFixtures { note: String = "", rating: Rating? = nil, isActionable: Bool = false,- lastSharedAt: Date = fixedDate+ lastSharedAt: Date = fixedDate,+ /// What the duplicate banner's filter keys on (Req 9.2). Nil is the+ /// ordinary row: in no published set, so the filter drops it.+ duplicateRoute: DuplicateResolutionRoute? = nil ) -> RecentPresentationRow { RecentPresentationRow( id: id,@@ -75,7 +78,8 @@ enum TestFixtures { actionType: isActionable ? .reteach : .none, note: note, rating: rating,- lastSharedAt: lastSharedAt+ lastSharedAt: lastSharedAt,+ duplicateRoute: duplicateRoute ) }
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 809577a..d7407c7 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -29,6 +29,40 @@ change was the share extension plist; it passed in isolation and on the full rerun. One isolated failure of this test is not a regression signal; rerun before investigating. +## Known flaky family: the store-digest comparisons (AsterismCore)++Several core tests assert that an operation which must not write left the+library byte-identical, by comparing a digest of the whole store *family* taken+before and after:++- `BootstrapActionTests.aFailedOpenChangesNothing` and+ `BootstrapActionTests.aLockTimeoutChangesNothing` (`try root.digest() == before`)+- `BootstrapClassifierTests`' "classifying the state changed it" cell+ (`BootstrapClassifierTests.swift:76`, `store=fullFamily`,+ `seededVersion=atOrAboveV5`)++**How it presents**: a *different* one of them fails per run, only in a full+`make test-core`, and it passes both in isolation (`CORE_TEST=…`) and on a clean+HEAD — so it reads like a regression the current branch caused. The classifier+cell failed in 2 of 3 full runs during `configurable-work-types` with five+isolated runs green; the two `BootstrapActionTests` cells did the same during+`recent-window-cap`, a branch that changes no file under `Packages/` at all.++**Root cause**: the digest hashes the store family, `-wal` included, and SQLite+chooses when to checkpoint the WAL back into the main file based on process-wide+state that earlier suites in the same run have influenced. Bytes therefore move+between the two hashes without a single row changing.++**Suggested hardening** if the rate persists: checkpoint the store (or exclude+the `-wal`/`-shm` files) before hashing, so the digest describes the data rather+than the journal. Recorded from the other side in+`specs/configurable-work-types/verification-run.md` and that spec's+`implementation.md`, for the classifier suite only.++**Before investigating**: re-run the single test in isolation and re-run the+full suite. Two consecutive full-run failures of the *same* cell would be new;+one failure of a different cell each time is this.+ ## UI reachability must be proven through navigation The M3 branch shipped with entire flows (URL teaching, Merge) existing only as
diff --git a/specs/recent-window-cap/decision_log.md b/specs/recent-window-cap/decision_log.mdnew file mode 100644index 0000000..ffb6825--- /dev/null+++ b/specs/recent-window-cap/decision_log.md@@ -0,0 +1,124 @@+# Decision Log: Recent Window Cap++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-14 | Full spec workflow, not smolspec | Banner-count restructuring, search scope change, and two spec conflicts exceed smolspec criteria |+| Q2 | 2026-08-14 | Feature name `recent-window-cap` retained after Decision 2 | The spec, branch and ticket references are established; renaming to match the narrowed scope would cost more than the slight misnomer |+| Q3 | 2026-08-15 | Cap fixed at 100 rows, not configurable *(amended by Decision 2 — the 14-day half is gone)* | Settings was just decluttered (T-2117); no demonstrated need to tune |+| Q4 | 2026-08-14 | Banner counts stay library-wide | Capping the list must not hide actionable or duplicate work |+| Q5 | 2026-08-14 | Active banner filter bypasses the cap | Counts and reachable rows always agree; every flagged entry is reachable from its banner |+| Q6 | 2026-08-15 | Recent search stays full-library while a query is active | Recent search is the app's only note-text/chapter-title search (Works matches display titles only); page-local scope would make older note text unsearchable anywhere. Preserves `polish-and-export` Req 3.1. Replaces an earlier page-local choice after review |+| Q7 | 2026-08-14 | Truncation footer links to the Works section | Discoverability: tells the user where excluded entries live and takes them there |+| Q8 | 2026-08-15 | Supersede only the reachability clauses of `immutable-capture-safety-net` Req 5.1 and `title-teaching-retroactive-parsing` Req 10.4 | Both mandate that Recent shows/reaches all entries, which T-2191 deliberately reverses; reachability moves to Works. ICSN 5.1's ordering definition is restated in Req 1.2; TTRP 10.4's publish budget survives unrestated, since it is defined at the 20k device scale for which no fixture exists (Q13) |+| Q9 | 2026-08-14 | *(superseded by Decision 2)* The 14-day cutoff derives from the repository's injected clock | A count-based cap needs no clock at all |+| Q10 | 2026-08-15 | *(superseded by Decision 2)* Calendar-day window: cutoff is start of day 13 days back | No cutoff exists to compute |+| Q11 | 2026-08-15 | *(superseded by Decision 2)* Window has a lower bound only; future-dated entries stay visible | Without a window, clock-skewed rows simply sort first as they do today |+| Q12 | 2026-08-15 | Banner filter bypass reconfirmed as unbounded after review challenge | Reviewer proposed bounded-plus-elsewhere-line; user kept the unbounded bypass — every flagged entry stays directly reachable, and the filtered view is opt-in and temporary |+| Q13 | 2026-08-15 | Perf requirement dropped its 20k target (no fixture exists; device target is approval-gated) | Final form settled in Q18: absolute host-scale budgets only, no relative check |+| Q14 | 2026-08-15 | The 100 cap counts logical presentation rows after identity grouping | Counting raw records would let torn/duplicate rows consume slots |+| Q15 | 2026-08-15 | *(superseded by Decision 2)* Window evaluated only at publication time | Nothing ages out, so there is no staleness to schedule around |+| Q16 | 2026-08-15 | *(superseded by Decision 2)* Window membership evaluated per logical row | The cap is positional; no row can straddle it |+| Q17 | 2026-08-15 | Simultaneous banner filter and search compose as today (both apply), uncapped | Both scopes bypass the cap individually; their combination inherits that |+| Q18 | 2026-08-15 | No new relative perf check; the existing absolute host-scale budgets carry forward (median every run, p95 under `CONTROLLED=1`) | A relative check adds nothing over the surviving budgets. The cap bounds output, not work — library-wide counts and full-library search keep the 5k-scale cost real |+| Q19 | 2026-08-15 | Accept the loss of per-record attention marking for unresolvable entries beyond the cap | Attention marking exists only on Recent rows; scoped supersession of `library-integrity-tolerance` Req 2.2 — aggregate coverage remains via the diagnosis banner → Library Check. Alternative (making the diagnosis banner a cap-bypassing filter) rejected as scope growth |+| Q20 | 2026-08-15 | *(superseded by Decision 2)* Perf and UI suites must be made to exercise a non-empty bounded list | A count-based cap over a 5,000-row fixture always yields a full 100-row list, whatever the fixture's timestamps |+| Q21 | 2026-08-15 | *(superseded by Decision 2)* Fixtures become clock-relative | No fixture change is needed at all |+| Q22 | 2026-08-15 | Works is reset via a `ContentView`-held token applied as `.id()`, not by hoisting the query into a `Binding` | Req 3.2 needs the Works root unfiltered, and a query hides Unattached Notes — which would break Req 1.3 reachability for the very rows the footer sends the reader to find. A hoisted `Binding` would rebuild `ContentView`'s body, and with it `RecentView`, on every keystroke in the Works search field; the token costs one invalidation per footer tap and leaves `WorksView`'s API untouched |+| Q23 | 2026-08-15 | *(superseded by Decision 2)* Generated differential property test for the prefix invariant | Without an age predicate the capped set is a plain prefix; a generated test would assert little beyond `prefix` itself, so the regrouping cases are covered by enumerated unit tests |+| Q24 | 2026-08-15 | *(superseded by Decision 2)* Rows carrying the `lastSharedAt` schema default are out-of-window | Such rows simply sort last, exactly as they do today |+| Q25 | 2026-08-15 | *(superseded by Decision 2)* Fixtures gain a `baseDate` parameter | No fixture change is needed at all |+| Q26 | 2026-08-15 | The Recent branch/footer decision is extracted into a `RecentDisplayPlan` value type | No app-layer test instantiates a SwiftUI view, so a decision left inside `body` is untestable; `RecentDuplicatePlan` is the existing precedent for this shape |+| Q27 | 2026-08-15 | *(superseded by Decision 2)* `RecentWindow` stores only `cutoff` and `rowLimit` | No window type exists; the cap is a defaulted parameter on `RecentRowCap.apply(to:limit:)`, so nothing is stored that could disagree with `groups` |+| Q28 | 2026-08-15 | The empty-library state survives an active search query | "No captures yet — share a page to begin" tells a reader with nothing captured more than "No results for 'zzz'". Preserves today's branch order (`RecentView.swift:131`) rather than reordering it |+| Q29 | 2026-08-15 | The cap lives in the app target beside `SearchFilters.swift`, not in `AsterismCore` | It is presentation logic over a published DTO, exactly what `SearchFilters` already is. Keeping it app-side leaves `AsterismCore` untouched end to end and avoids a public package contract for a number the screen owns |+| Q30 | 2026-08-15 | `RecentRowCap.apply` returns capped groups and excluded count together | Two functions each taking a defaulted limit could be called with different values with nothing to catch it — the same disagreement hazard that rules out storing the count on the DTO — and it walks the groups once |+| Q31 | 2026-08-15 | The Req 3.2 UI test asserts the Works **search placeholder** rather than an "Unattached Notes present" element | An empty search field reports its placeholder as its value, and the unattached group is hidden precisely while a query is active (`WorksView:105`), so the placeholder proves the query was cleared. The `unattached-section-header` itself sits below all 1,000 seeded work rows, and a lazy `List` never puts it in the accessibility tree at that scale — asserting it would assert the fold, not the screen. **Consequence**: Req 1.3's "including Unattached Notes" is asserted only by proxy in the scale journey; `SearchUITests.testWorksSearchNarrowsAndHidesTheUnattachedGroup` asserts the header directly, but on the 3-entry fixture |+| Q32 | 2026-08-15 | The footer journey scrolls through the **shared** helper with a raised `attempts:` and the list's own swipe, rather than pre-swiping around it | `scrollUntilTappableAndTap`'s eight `app.swipeUp()` passes cannot reach a footer sitting under 100 rows in a single day group, and the first version answered that with a hand-rolled 30-pass pre-loop — two overlapping scroll drivers, one swiping the list and one swiping the app. The helper instead gained `attempts:` (two private copies already had it) and a `scroll:` closure (the precedent is `expandDisclosure` in the same file), both defaulted to exactly today's behaviour, so every existing caller is unchanged and the journey is one call. The footer is also queried as `app.buttons[…]` rather than `anyElement`, whose `.any` descendant match forces a full accessibility snapshot on every `exists` check |+| Q33 | 2026-08-15 | Req 6.1's publish budgets were **not** re-measured for this branch | The budgets time `repository.recentPresentation(calendar:)` — both host suites call it directly, and the device signpost brackets the same function — while the cap runs strictly after publication, inside the view. Zero files under `Packages/` changed on this branch, so nothing any budget times executes app-target code at all. `make test-performance-m4` is host-only and safe to run, but it is ~20 minutes and would measure unchanged code; the device target is approval-gated and was not run |++---++## Decision 1: The cap is a presentation boundary, not a derivation filter++**Date**: 2026-08-15+**Status**: accepted++### Context++Recent's publication fetches every Entry, Work and Site, derives duplicate sets and counts from those rows, builds a row per logical record, and returns them ordered and day-grouped. Capping the screen at 100 rows could be implemented by narrowing what the publication derives (a fetch predicate, a `fetchLimit`, or truncating row emission) or by leaving the derivation alone and bounding only what the screen renders.++The choice is not free: banner filtering and search are applied in the view over the already-published groups, and three surfaces outside Recent read `allRows` to decide whether teaching can be entered for an entry or hostname.++### Decision++`RecentPresentation.groups` keeps its current meaning — every logical row, ordered, day-grouped. The cap is applied where the screen reads it, through `RecentRowCap.apply(to:limit:)` (named `cappedGroups(limit:)` when this decision was written; see Q29 and Q30); the repository is unchanged and everything else reads what it reads today.++### Rationale++Four requirements independently need the complete row set to exist after derivation: library-wide counts (2.1), teaching lookups over `allRows` (2.2), banner filters showing every match regardless of the cap (2.3), and full-library search (4.1). A derivation-side cap cannot satisfy any of them without a second unbounded read, which would reintroduce the split-moment problem the single locked observation exists to prevent.++Because rows are ordered by `lastSharedAt` descending, the newest 100 are the first 100 of the ordered set. The boundary is therefore a prefix and a regrouping rather than a parallel projection.++### Alternatives Considered++- **Fetch predicate or `fetchLimit` on the Entry fetch**: Would bound the query itself — rejected because the duplicate workload, the diagnosis count and the actionable count are all derived from that same fetch, so bounding it shrinks the banners that Req 2.1 requires to stay library-wide, and strands counted duplicate sets with no row to filter to.+- **Truncating row emission after derivation**: Counts would stay correct, but `allRows` would lose the capped rows that the Sites re-teach check and the two teaching lookups depend on, disabling re-teach for any site whose entries all fall beyond the newest 100 and opening an empty sheet from the diagnosis screen.+- **A second, bounded read for the screen**: Rejected — two reads of a store the share extension also writes give the banner one moment's counts over another moment's rows.++### Consequences++**Positive:**+- Every existing consumer of `groups`, `allRows` and `duplicateWorkload` is correct unchanged, including 46 core test call sites.+- Reqs 2.2 and 2.3 hold by construction rather than by a rule an implementer must remember.+- The capped slice is a prefix, so it is a cheap walk with no second sort or filter, and `LibraryRepository` needs no change at all.++**Negative:**+- No publish-latency win: the publication does exactly the work it does today, and the cap only reduces what SwiftUI renders. Req 6 is a no-regression guard, not an improvement target.+- The DTO carries every row in memory, as it does today, so a very large library's presentation is unchanged in size.++---++## Decision 2: Cap by count alone, with no age cutoff++**Date**: 2026-08-15+**Status**: accepted++### Context++The feature was specified as the newest 100 rows *within the last 14 calendar days*. Both bounds were applied together, so a reader who captured nothing for a fortnight would open Recent to an empty list — while the app's own empty state told them there was no recent activity, their library sitting untouched one tab away.++### Decision++The Recent list is capped at the 100 most recently shared logical rows, with no age-based exclusion. A reader always sees their newest entries, however long ago they were captured.++### Rationale++The complaint behind T-2191 is a list too long to be useful, which the count cap fully answers. The age cutoff answered nothing extra and introduced the failure mode above: the one reader guaranteed to hit it is the one returning after a break, for whom an empty screen is least informative.++Removing it also deletes most of the feature's machinery — the repository clock, calendar arithmetic and its DST edge case, the no-upper-bound rule for clock-skewed rows, the publication-time re-evaluation question, the no-recent-activity empty state, and every fixture and performance-suite change the window would have forced.++### Alternatives Considered++- **Keep both bounds as specified**: Rejected — empties Recent for an absent reader, the case the page is least able to explain.+- **Window with a minimum-count floor** (last 14 days, but never fewer than N rows): Rejected — it is the count cap with an extra rule that only ever changes behaviour when the window is the wrong answer, which is an argument for dropping the window rather than qualifying it.+- **Age cutoff far longer than 14 days** (e.g. a year): Rejected — it postpones the same failure rather than removing it, and adds a bound that would almost never bind.++### Consequences++**Positive:**+- Recent is never empty while the library is not.+- No clock, calendar or time-dependence anywhere in the feature; the cap is a pure function of the ordered rows.+- No fixture, performance-suite or UI-suite changes: a 5,000-row fixture still yields a full 100-row list regardless of its timestamps.++**Negative:**+- The truncation footer is permanent for any library over 100 entries, rather than appearing only when older rows age out.+- Recent can show entries of any age, so "Recent" means "newest" rather than "recently".+- The last day group is permanently partial for any library over 100 rows, under an ordinary day header with nothing marking it incomplete. The footer is the only disclosure, which Req 1.2 accepts.++### Impact++Supersedes the window half of Decision 1 and quick decisions Q9, Q10, Q11, Q15, Q21, Q24 and Q25.++---
diff --git a/specs/immutable-capture-safety-net/requirements.md b/specs/immutable-capture-safety-net/requirements.mdindex 2809a3e..1f8e276 100644--- a/specs/immutable-capture-safety-net/requirements.md+++ b/specs/immutable-capture-safety-net/requirements.md@@ -85,7 +85,7 @@ Milestone 1 establishes Asterism’s durable local data foundation and the minim **Acceptance Criteria:** -1. <a name="5.1"></a>The Recent view SHALL show all Entries ordered by last-shared timestamp from newest to oldest, then UUID ascending, and grouped using the user’s current calendar and time zone.+1. <a name="5.1"></a>The Recent view SHALL show all Entries ordered by last-shared timestamp from newest to oldest, then UUID ascending, and grouped using the user’s current calendar and time zone. **Superseded in part** (2026-08-15) — the "show all Entries" clause is reversed by [`specs/recent-window-cap/`](../recent-window-cap/requirements.md) Req 1.1, which caps the Recent list at the newest 100 logical rows; the rest of the library stays reachable through the Works section (its Req 1.3). The ordering and day-grouping definition above survives unchanged and is restated in that spec's Req 1.2. 2. <a name="5.2"></a>WHEN an Entry has no parsed Work or chapter title, its row SHALL show the raw capture title, hostname, note preview, last-shared time, and rating when present. 3. <a name="5.3"></a>WHEN an Entry row is selected, the app SHALL present its title, hostname, last-shared timestamp, raw URL link, editable note and rating, and Move to action. 4. <a name="5.4"></a>WHEN a note or rating is edited in the app, the system SHALL update modified time without changing first-captured or last-shared time.diff --git a/specs/library-integrity-tolerance/requirements.md b/specs/library-integrity-tolerance/requirements.mdindex 1786577..1921d74 100644--- a/specs/library-integrity-tolerance/requirements.md+++ b/specs/library-integrity-tolerance/requirements.md@@ -42,7 +42,7 @@ Reference: `docs/asterism-design.md` §2.4, §9, §10, §14 (M4). **Acceptance Criteria:** 1. <a name="2.1"></a>Recent, Works, Work detail, and Entry detail SHALL render every record they can resolve in each state from [1.1](#1.1), and SHALL NOT fail a whole screen because of one record.-2. <a name="2.2"></a>WHERE a row cannot be fully resolved, it SHALL still appear — identified by its capture title for an Entry, or its display title for a Work — and SHALL be marked as needing attention. The unresolvable causes are exactly two: no Site row for the hostname, and a missing referenced Work.+2. <a name="2.2"></a>WHERE a row cannot be fully resolved, it SHALL still appear — identified by its capture title for an Entry, or its display title for a Work — and SHALL be marked as needing attention. The unresolvable causes are exactly two: no Site row for the hostname, and a missing referenced Work. **Superseded in part** (2026-08-15) — the Recent-visibility clause is scoped by [`specs/recent-window-cap/`](../recent-window-cap/requirements.md) Req 1.1, which caps the Recent list at the newest 100 logical rows: an unresolvable Entry beyond the cap is neither listed nor attention-marked in Recent, and per-record attention marking exists nowhere else, so coverage for it is the aggregate path (diagnosis banner → Library Check). Within the cap, and on every other screen this criterion names, it applies unchanged. 3. <a name="2.3"></a>WHERE more than one Site row exists for a hostname, Site lookup SHALL return one of them by a stated rule rather than throwing, and SHALL return the same row for the same store contents on every call and across relaunches. 4. <a name="2.4"></a>Capture SHALL succeed in each state from [1.1](#1.1), including matching against a hostname carrying more than one Site row. 5. <a name="2.5"></a>Teaching, re-parsing, Move to…, and Merge SHALL either operate normally on unaffected records or refuse with a typed reason naming what blocks them — never fail unhandled.diff --git a/specs/title-teaching-retroactive-parsing/requirements.md b/specs/title-teaching-retroactive-parsing/requirements.mdindex d2154ea..655de30 100644--- a/specs/title-teaching-retroactive-parsing/requirements.md+++ b/specs/title-teaching-retroactive-parsing/requirements.md@@ -190,4 +190,4 @@ Milestone 2 teaches Asterism how each Site encodes Work and chapter titles, then 1. <a name="10.1"></a>Performance tests SHALL use repeatable data containing exactly 20,000 Entries and 2,000 Works across multiple Sites, with exactly 5,000 Entries for one Site, representative manual and parsed provenance, pattern history, actionable states, and ten scripted edits for each enabled teaching form. 2. <a name="10.2"></a>Measurements SHALL run in a Release build on the slowest supported physical iPhone under nominal thermal conditions with Low Power Mode disabled, record the device model and OS version, reset to a consistent fixture state, perform one warm-up, and collect 20 runs. Acknowledgement SHALL be measured from edit delivery to publication of that generation in view state; final preview from final-edit delivery to complete final projection publication; and Recent from repository request to complete ordered-result/actionable-count publication and UI-observed first-screen interaction. Nearest-rank p95 SHALL be the 19th sorted value of 20. 3. <a name="10.3"></a>WHEN the ten scripted edits are delivered 50 milliseconds apart, obsolete preview generations SHALL NOT replace newer state; every accepted edit SHALL be acknowledged at p95 within 100 milliseconds, and the complete final projection SHALL publish at p95 within 1 second. -4. <a name="10.4"></a>After the repository is open, Recent SHALL publish its complete ordered result and actionable count and present an interactive first screen at p95 within 2 seconds across 20 measured runs; all 20,000 results SHALL remain reachable without requiring eager rendering of every row. +4. <a name="10.4"></a>After the repository is open, Recent SHALL publish its complete ordered result and actionable count and present an interactive first screen at p95 within 2 seconds across 20 measured runs; all 20,000 results SHALL remain reachable without requiring eager rendering of every row. **Superseded in part** (2026-08-15) — the Recent-reachability clause is reversed by [`specs/recent-window-cap/`](../recent-window-cap/requirements.md) Req 1.1, which caps the Recent list at the newest 100 logical rows; reachability for the rest moves to the Works section (its Req 1.3). The publish budget stated above is unaffected and still applies at this 20,000-entry device scale.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d4b56b2..4d77a69 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -36,6 +36,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **The Recent list now shows your newest 100 entries instead of your whole library** (`specs/recent-window-cap/`, T-2191, all eight tasks). Rows keep their existing order and day grouping, and the cap is by count alone — there is no age cutoff, so returning after a long break still fills the page (Decision 2 dropped a 14-day window for exactly that reason, and dropping it also removed the repository clock, the calendar arithmetic and every fixture and performance-suite change the window would have forced). Nothing is deleted or hidden: when rows fall beyond the cap a footer reading "Older entries are in Works" appears below the list and taps through to the Works root with any previous query and pushed detail cleared, so capped rows — Unattached Notes included — stay reachable in one gesture. The cap is a presentation bound only, and everything that needs the whole library still sees it: the actionable, diagnosis and duplicate-review counts stay library-wide, teaching lookups are unchanged, and both banner filters and search deliberately bypass the cap so a filter or a query still reaches every matching row at any age. Empty-library and search-miss states survive unchanged, including their documented exceptions (the duplicate filter over an empty library, and a no-match query that still has an "elsewhere" line). Applied where the screen reads the presentation rather than inside the derivation (Decision 1), so `AsterismCore`, the fixtures and the performance suites are untouched and the change is entirely app-layer. Internally this adds `RecentRowCap.apply` (one walk yielding both the capped groups and the excluded count, Q30) and a `RecentDisplayPlan` that absorbs the four computed properties and branch ladder `RecentView` used to carry, leaving one plan built per body evaluation. Covered by 9 row-cap tests, 21 display-plan tests — one of which pins the production limit at 100 through the defaulted path, the only thing standing between a stray default change and a silently wrong screen — and a dedicated M4 scale UI journey that dirties the Works state, scrolls the footer into view at 5,000 entries and asserts the route lands unfiltered. One coverage gap is recorded rather than papered over: the library-wide banner counts are guarded by a code comment and no regression test, because the plan does not own those counts and a plan-level test would be vacuous. - The configurable-work-types feature is complete and verified end to end (Verification phase, `specs/configurable-work-types/`, task 21; results in that spec's `verification-run.md`). The full suites pass with no regressions and no new compiler warnings on verified forced recompiles: the AsterismCore package (host), the unit bundle, the **full UI bundle** (76 tests — its first complete run since work-type seeding changed every journey's launch path), and the combined simulator suite. The reconciliation suites pass with zero expectation changes (requirement 8.1, audited as a diff). A new test pins requirement 7.1 end to end: an unrecognised stored type reads without a corruption throw and exports verbatim in the 5/6 record. One release gate remains open, recorded as **Q54**: `WorkTypeEntity` is the first entity added to the schema under live CloudKit mirroring (measured from git history — mirroring went live 2026-08-01 with the schema already at V5, and nothing added an entity since), so no precedent shows that a pre-feature build's mirror import tolerates the new record type and column. **This blocks release, not merge** — closing it needs a pre-feature build and this build against the same CloudKit container, which is physical-device work requiring explicit approval at the time it runs. The known `BootstrapClassifierTests` digest flake fired in post-phase full-suite re-runs more often than earlier phases saw (2 of 3, never in isolation); `verification-run.md` records the observation and the suggested hardening if it persists. - Configurable work types are now a complete user-facing feature: restore and UI landed (Backup Format 5/6 + App UI phases, `specs/configurable-work-types/`, two parallel streams). Import accepts the 5/6 format the app already exported — the archive's type list folds against itself, then merges additively into the local library (id match keeps the local spelling, a cross-library name match imports as an alias row, and a work's `typeName` snapshot can mint the entry outright), with archive-level timestamps throughout except the deliberate restore-now of a locally removed type (Q33); V4 archives keep importing unchanged, their `.other` records untyping only what a V4 archive could actually express (Q35). Settings gains a Work Types screen between Sites and Backup: add (re-adding a removed name restores it), rename (case corrections allowed, collisions refused with the typed name kept), and non-destructive remove with a confirmation that states the usage count and promises works keep their label; removed-but-used types sit in their own subsection. The work editor's picker now offers the full active list alphabetically under the em-dash blank row, carrying a removed/legacy/unresolved assignment as its own selectable row, and removed/legacy pills across the app dim to the violet recipe at half opacity (Q23). UI journeys cover the settings screen and picker end to end. Core, app-unit, and the new UI suites are green with no new warnings; the phase also repaired the UI-test fixture guard that work-type seeding had invalidated. Only task 21 (full-suite verification) remains. - The library's repository surfaces speak configurable work types end to end (Repository Surfaces phase, `specs/configurable-work-types/`, tasks 8–14). Work snapshots now carry a `WorkTypeDisplay` (configured, legacy, unrecognised, unresolved, or untyped) instead of the closed enum, and an unrecognised stored type is no longer diagnosed as corruption — the old `corruptLibrary("invalid Work type")` throw is gone (Decision 5), which also completes requirement 7.1's upstream half for export. All type writes go through shared writers: assignments write `typeRaw = "other"` beside the identity UUID (the pre-feature-edit detection of Q27), and name/state edits fan out to every local row of the identity with per-field timestamps (Decision 10), value-guarded so the reconciler is a fixed point (Q48) and a rename to the exact current spelling writes nothing at all (Q49). The settings-facing repository API lands (add with restore-on-readd, case-permitting rename, non-destructive remove, per-logical-record usage counts), group ordering uses rename-invariant order tokens, the new `WorkTypeReconciler` converges duplicate type rows before the duplicate-entry phase with carrier gates that propagate only legacy and active-configured types (8.4), the variant/merge/resolution surfaces present and commit assignments through the same writers, and markdown export labels works from the display name. 1,459 core tests across 157 suites and the app unit bundle pass with no new warnings; the phase also repaired an app-target test the seeding phase had silently broken.diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex d387abf..1373abb 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). |+| [Recent Window Cap](#recent-window-cap) | 2026-08-15 | Done — all 8 tasks implemented, reviewed and green | Caps the Recent list at the newest 100 logical rows (T-2191), with everything beyond it reachable through Works behind a truncation footer. A 14-day window was specified and then dropped (Decision 2): it would empty Recent for a reader returning after a break. Entirely app-layer — `AsterismCore`, the fixtures and the performance suites are untouched, because the cap is applied where the screen reads the presentation rather than in the derivation (Decision 1). | --- @@ -271,3 +272,12 @@ 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)++## Recent Window Cap++Caps the Recent list at the newest 100 logical rows (T-2191), so a page that listed the whole library becomes a recency view again; everything beyond the cap stays reachable through Works, behind a truncation footer that routes there unfiltered. A 14-day window was specified alongside the cap and then dropped (Decision 2) — it would have emptied Recent for a reader returning after a break, and removing it deleted the clock, the calendar arithmetic and every fixture and performance-suite change the window would have forced. The cap is applied where the screen reads the presentation rather than in the derivation (Decision 1), because library-wide banner counts, the `allRows` teaching lookups, the banner-filter bypass and full-library search all need the complete row set; `AsterismCore` is untouched end to end.++- [requirements.md](recent-window-cap/requirements.md)+- [design.md](recent-window-cap/design.md)+- [decision_log.md](recent-window-cap/decision_log.md)+- [tasks.md](recent-window-cap/tasks.md)
The library-wide banner counts and the teaching entry points are correct because this branch does not touch them — the guard is a code comment at RecentView.swift:102-104. No test fails if a future edit points a banner at plan.groups instead of presentation. A plan-level test would be vacuous, since the plan does not own the counts, and the repo has no view-level snapshot testing. Recorded as a known gap in design.md and the changelog rather than implied as covered.
The argument was verified against the diff, not taken on trust: zero files under Packages/ changed, and both host suites time repository.recentPresentation(calendar:) directly while the device signpost brackets that same function. Nothing any budget times enters app-target code. If you want it closed empirically anyway, make test-performance-m4 is host-only and safe — roughly 20 minutes.
The UI test confirms the Works search field shows its placeholder rather than the previously typed query, which is the precondition for Unattached Notes being visible — it does not observe the group itself, because that header sits below 1,000 lazy rows and never enters the accessibility tree. SearchUITests covers the underlying behaviour on a small fixture.
origin/main gained two commits while this branch was in review (aabf2c4 sharesheet polish, 5920e86 T-2156). Both this branch and those touch CHANGELOG.md, and specs/OVERVIEW.md is likely too. Expect to resolve by keeping both sides' entries.
BootstrapActionTests.aFailedOpenChangesNothing and aLockTimeoutChangesNothing, both asserting try root.digest() == before, failed twice during this work — a different cell each time, green in isolation and green on clean HEAD. Unrelated to this branch (it touches no package files), now documented in docs/agent-notes/testing.md. If CI goes red there, re-run before investigating.