The complete M5 milestone — markdown export, search, the rebuilt work detail with deletion, the Sites screen, and the Constellation visual pass — reviewed by four parallel agents (reuse, quality, efficiency, spec & docs) on top of the per-phase design reviews that ran during implementation. One major and ~20 minor findings; everything actionable fixed in-review, one deliberate open item left for the author.
redirectWork extraction, a shared export-share modifier, ExportStaging unifying file staging with the backup exporter, per-keystroke search pipeline hoists, and a dozen smaller consolidations.MarkdownExportModel still scavenges in init — the fix is ready but needs a one-line test retitle the review constraints forbade.Ready to push
All 34 spec tasks are complete with the deviations recorded (Q32–Q46). The review's one major finding (a hand-rolled amber attention surface, tripled, that dropped its Reduce Transparency arm) and fifteen minors were fixed in commits 9cce855, bdcfb11 and fb1b5c2; four minors were deliberately skipped with reasons recorded below. Final tree: make test-core, make test-quick and the full make test-ui bundle (65 tests) all green, zero compiler warnings. One open item needs an author decision: moving the export scavenge out of init requires retitling a test that pins init-time behaviour.
c29a29a [feat]: ConstellationKit — adaptive tokens and the Constellation foundation 402a18d [feat]: restyle the share-extension capture sheet (Constellation) 06a606c [feat]: M5 Core stream 1 — markdown export, sites/work-detail reads, work deletion 50b1089 [merge]: phase Core stream 1 762827c [merge]: phase Core stream 2 f88d262 [feat] polish-and-export (M5): review fixes to export tolerance and deletion diagnoses b7723f2 [doc]: changelog for phase Core 5066bd3 [doc]: polish-and-export spec bookkeeping after phase Core 0408822 [feat]: search on Recent and Works 0c1c92f [feat]: markdown export from Entry detail 1e37636 [feat]: work detail in the §6 shape, with work deletion 59efa62 [feat]: the Sites settings screen 59f6e00 [bug] Work deletion: carry the contract on the presented value b120386 [doc]: changelog for phase App functional 4ee09e3 [feat]: Constellation adoption across the app (tasks 28, 29, 30, 33) 02f2ce2 [test]: UI journeys for search, work detail, and the Sites screen (task 32) 7368dc9 [merge]: phase Visual pass stream 1 00f5cd0 [merge]: phase Visual pass stream 2 11a28b0 [test]: follow Merge and URL identity into the Work-detail menu a1df9f9 [feat] Visual-pass review fixes: finish Q36, give two sheets their primary 69b434e [doc]: changelog for phase Visual pass ed58d8d [doc] Amend the design doc and style guide for M5 Decisions 1 and 4 4a21d3a [doc]: changelog for phase Documentation cdd6430 [doc]: polish-and-export spec is Done 9cce855 [doc]: design doc catches up with the shipped Sites list and Work-detail menu bdcfb11 [feat] Pre-push review fixes: one attention recipe, shared redirect, shared export staging fb1b5c2 [feat]: hoist the per-keystroke search pipeline to one evaluation per body 2e0ffbb [doc]: implementation explanation for polish-and-export (pre-push review) This branch finishes the app's version 1. Five things are new: search on both main tabs (accent- and case-insensitive), export of an entry or a whole work as a markdown file you can share anywhere, a rebuilt work screen with a jump-to-last-chapter button and a rating history, work deletion that states exactly how many notes it takes and offers to keep them as unattached instead, and a Sites screen in Settings plus a full visual refresh ("Constellation") — night-sky backdrop, card rows, site glyphs, serif titles, and a real light mode.
Your notes stop being locked in: search finds them, export gets them out. Deletion closes the last place a mistake was permanent. And one colour now means one thing everywhere: amber = "this needs you", cyan = "yes/confirmed".
Four phases: Core — MarkdownExport (pure renderer + filename API), repository reads for export/sites/work-detail, work deletion as a projection/commit pair, a flagged articles→taught conversion. ConstellationKit — a new library target owning adaptive colour tokens, typography, layout, surfaces/recipes, SiteGlyph (FNV-1a hue), and the sky; the hard-coded-dark AsterismStyle is deleted. App — pure search-filter structs, MarkdownExportModel, the §6 WorkDetailView, Sites models/views, Constellation adoption everywhere. Share extension — same tokens, now light/dark adaptive.
applyManualAssignment centralises the leave-unattached write, closing a hole where detach and moveEntry left URL-rule references on .manual rows — a state the validator refuses.Locale (en_AU pinned in tests, .current production overloads); escaping owns the full metacharacter set; heading/work/date are separate paragraphs (Q18) because adjacent lines merge in CommonMark.recordNotFound and rethrows real store errors; block ordering reuses GroupOrdering.sortedSurvivorCandidates so the tie-break has one spelling..refreshed (Q28); the validator gate compares against the hostname's prior diagnosis (Q34, adopting title-teaching's Decision 8), and the post-commit diagnosis refresh clears stale quarantines without a relaunch.isPresented = false (running the cancel path) before the button's Task body. Confirm paths that re-read cleared model state no-op silently — invisible to tests that call methods in reader order. Regression tests now call the two in SwiftUI's order.LibraryProviding grew six requirements; ConstellationKit is now the single owner of colour semantics (amber widened to "actionable attention" per Decision 1, error red eliminated per Q37, chips flipped to §7's mapping per Q36). UI seeding gained m5RepublishDiagnoses() because M5 fixtures write post-open, exposing quarantine-dependent paths to test for the first time.
MarkdownExportModel scavenges in init, rebuilt per body re-evaluation of a presented screen — fix requires a deliberate test retitle (open item).Packages/AsterismCore/Sources/AsterismCore/MarkdownExport.swift
Why it matters. The export feature's correctness core: escaping, paragraph structure, and filename sanitisation all live here, golden-tested against an adversarial title table.
What to look at. MarkdownExport.swift (whole file); input reads in LibraryRepository+Export.swift
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift
Why it matters. First destructive work-level operation; the machinery guarantees the reader is never shown one number while the commit takes another.
What to look at. LibraryRepository+WorkDeletion.swift; WorkDetailModel deletion flow
Asterism/Asterism/ViewModels/WorkDetailModel.swift
Why it matters. Two shipped-in-branch bugs (dead delete buttons, no-op articles switch) came from the same SwiftUI ordering trap; the fix pattern and its regression-test shape now cover all four dialogs in the app.
What to look at. WorkDetailModel.swift WorkDeletionPrompt/WorkDeleteDisclosure; SitesModels.swift ArticlesPrompt
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift
Why it matters. Closed a silent-corruption hole: moveEntry saved .manual rows still carrying URL-rule references — a state the V4 validator refuses, so the next open would quarantine the library.
What to look at. LibraryRepository.swift applyManualAssignment; adopted by moveEntry, the detach disposition, and the articles conversion
Packages/AsterismCore/Sources/ConstellationKit/AsterismColors.swift
Why it matters. Deleting the hard-coded-dark accessors made the compiler enumerate every call site (Q23), which is what made light mode and the semantic colour rules (amber = actionable attention, no error red) enforceable rather than aspirational.
What to look at. Packages/AsterismCore/Sources/ConstellationKit/ (11 files)
Packages/AsterismCore/Sources/AsterismCore/ExportStaging.swift
Why it matters. The markdown export model had copied the backup exporter's staging and scavenging near-verbatim — two spellings of the completeUnlessOpen protection attribute and the 24-hour cutoff, the things that must not drift.
What to look at. ExportStaging.swift; call sites in BackupV4Exporter.swift and MarkdownExportModel.swift
Asterism/Asterism/ViewModels/SearchFilters.swift
Why it matters. Search lands on both hot screens without a repository round-trip, and the ordering rule (after banner filters) is what keeps the duplicate-review workflow searchable.
What to look at. SearchFilters.swift; wiring in RecentView.swift / WorksView.swift
The closed tuple table refuses a non-articles site holding a junk rule, and that check gates every library open — the design's "retained inert" produces a library the app will not open (verified by probe). Likewise .none + intentionally-unattached is legal only in articles mode, so the mode flip alone would invalidate every marked entry and roll the conversion back. The conversion clears the rule and writes the same .manual unattachment moveEntry writes.
Merge's nil-compare is what made diagnosed hostnames unteachable until title-teaching's Decision 8. A work on an already-diagnosed hostname must stay deletable; the commit refuses only on a different problem than the one already recorded, and refreshes the diagnosis after commit.
The Core phase codified the guide's mapping while shipped chips carried the inverse; the flip landed with adoption, including the name previews under the chips. The palette has no error colour and §11 forbids a fourth accent hue, so recoverable failures are amber banners and confirmations cyan — consistently in app and extension.
Measured, not assumed: a stack-level background renders behind the stack's opaque backing and never reaches the screen (sampled pure black; sampled the auras once moved inside). Screens that also present in sheets take a showsSky flag.
The setting cannot be toggled from a simulator UI test (verified against rendered pixels via both the launch argument and Accessibility defaults). The requirement is held by the replace-not-layer branch in the Constellation surfaces plus a reachability journey.
Export filenames come from reader titles (Q11) so no prefix exists to match. The scavenger owns its private MarkdownExports cache directory, distinct from the backup exporter's, so extension-based matching cannot touch other features' files.
The one documented exception to §8.4's header treatment: uppercasing and letter-spacing a question reads as shouting, and dimming it drops the actionable-attention signal Decision 1 licenses on duplicate-review surfaces.
In-review choice: when the three hand-rolled attention surfaces became one recipe, the border token aligned with ConstellationPillKind.attention.border (0.45). The share extension's error banner border is the only intended visual change from the review fixes.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | ConstellationKit / attention surfaces | The amber attention surface was hand-rolled three times (twice byte-identical in the share extension, once in ComposedTeachingView), and the copies dropped the Reduce Transparency arm the pre-restyle banner had. | New ConstellationAttentionSurface recipe with the RT arm; all three sites adopted; attentionBorder token added and used at the six magic-opacity call sites. Border unified at 0.45 (was 0.35 in the extension). |
| minor | LibraryRepository+Redirect | resolveWorkDeletionTarget re-spelled resolveWorkWriteTarget's candidate scan; the entry side already factors this through one redirectEntry. | Extracted private redirectWork; both public functions are thin wrappers mirroring the entry side. |
| minor | Export share plumbing | EntryDetailView and WorkDetailView duplicated the @State/onChange/sheet/dismiss plumbing, mirroring model state the presented-value pattern makes unnecessary. | One markdownExportShare(model:) modifier presenting off a derived Binding; @State and onChange removed from both views. |
| minor | MarkdownExportModel / BackupV4Exporter | Staging (atomic write + completeUnlessOpen + remove-on-failure) and 24h scavenging were near-verbatim copies across two targets. | Shared ExportStaging utility in AsterismCore; both call sites adopted. Directory creation stays at call sites to preserve the backup exporter's distinct error mapping. |
| minor | MarkdownExportModel.init | Synchronous directory scavenge in init, and the model is rebuilt on every body re-evaluation of a presented detail screen. | Skipped: MarkdownExportModelTests pins init-time scavenging, and the review constraint forbids test edits. A once-per-process guard would contaminate sibling tests. Landing it needs a deliberate one-line test retitle plus moving the call to startExport() — flagged for the author. |
| minor | RecentView / WorksView search | RecentView ran the filter pipeline twice per body evaluation; WorksView applied its filter three times plus two partition traversals — per keystroke. | Hoisted to one evaluation per body pass in both views (commit fb1b5c2); search UI suite re-run green. |
| minor | WorkDetailModel | Stored `work` mirrored presentation?.work — the exact two-moments incoherence the single workDetail(id:) read was introduced to end. | Now a computed property over presentation. |
| minor | Delete disclosures | WorkDeleteDisclosure.lines was byte-identical to EntryDetailModel's DeleteDisclosure.lines. | Shared disclosureLines(_:) helper; both alerts keep identical wording. |
| minor | LibraryRepository+Export | Block ordering re-implemented the survivor comparator (earliest firstCapturedAt, lowercased-UUID tie-break) inline beside GroupOrdering.sortedSurvivorCandidates. | Reused via the SurvivorCandidate mapping pattern; ordering byte-identical. |
| minor | Sites / Export reads | The blank-displayName→hostname fallback was spelled in two new files. | One siteDisplayName(_:hostname:) helper used by both. |
| minor | Layout aliases | ComposedTeachingPresentation.minimumHitTarget and WorkURLDetailPresentation.minimumHitTarget aliased AsterismLayout.minHitTarget. | ComposedTeachingPresentation's deleted (five call sites migrated). WorkURLDetailPresentation's kept — a test references it directly and test edits were out of bounds. |
| minor | Count sentences | Four new sites hand-rolled pluralised count sentences beside the existing Pluralisation helper. | All four routed through Pluralisation; every user-visible string unchanged. |
| minor | LibraryRepository+WorkDeletion API | disclosableVariantIDs was public but consumed only by a test. | Now internal; the test compiles unedited via @testable. |
| minor | docs/asterism-design.md | Two lines went stale: the Sites list described as "taught sites" (Q10 broadened it to every stored site) and the §6 toolbar-menu line omitted Delete work (Req 5.5 mandates it). | Both amended (commit 9cce855). |
| minor | RecentView | Identity closure wrapper around onResolveDuplicate. | Passed directly. |
| minor | Detail views | duplicateReviewSection is near-identical between Entry and Work detail (word and identifiers differ). | Skipped: two occurrences is tolerable; a third consumer justifies the shared DuplicateReviewNotice component. Recorded here so the third copy doesn't land silently. |
| minor | Share extension chrome | CaptureSheetChrome duplicates ConstellationSheetSurface's reduce-transparency arm with a Rectangle because the system sheet owns the corner shape. | Skipped: the deviation is reasoned and local; a shape-generic recipe variant is possible but low-value until a second consumer exists. |
| minor | RecentView empty branches | Searching an empty library shows the generic "No captures yet" branch rather than the search-specific empty state (branch ordering). | Skipped: AC 3.4's letter is satisfied by an empty state; the edge (query against a zero-entry library) is trivial and the current copy is arguably more informative. |
Click to expand.
diff --git a/Asterism/Asterism.xcodeproj/project.pbxproj b/Asterism/Asterism.xcodeproj/project.pbxprojindex b9472ec..5b430c9 100644--- a/Asterism/Asterism.xcodeproj/project.pbxproj+++ b/Asterism/Asterism.xcodeproj/project.pbxproj@@ -11,6 +11,9 @@ A10000000000000000000002 /* AsterismCore in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000012 /* AsterismCore */; }; A10000000000000000000003 /* AsterismShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A10000000000000000000004 /* AsterismShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A10000000000000000000014 /* AsterismCore in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000013 /* AsterismCore */; };+ A10000000000000000000018 /* ConstellationKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000001B /* ConstellationKit */; };+ A10000000000000000000019 /* ConstellationKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000001C /* ConstellationKit */; };+ A1000000000000000000001A /* ConstellationKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000001D /* ConstellationKit */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */@@ -110,6 +113,7 @@ buildActionMask = 2147483647; files = ( A10000000000000000000002 /* AsterismCore in Frameworks */,+ A10000000000000000000019 /* ConstellationKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; };@@ -118,6 +122,7 @@ buildActionMask = 2147483647; files = ( A10000000000000000000001 /* AsterismCore in Frameworks */,+ A10000000000000000000018 /* ConstellationKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; };@@ -126,6 +131,7 @@ buildActionMask = 2147483647; files = ( A10000000000000000000014 /* AsterismCore in Frameworks */,+ A1000000000000000000001A /* ConstellationKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; };@@ -183,6 +189,7 @@ name = AsterismShareExtension; packageProductDependencies = ( A10000000000000000000012 /* AsterismCore */,+ A1000000000000000000001C /* ConstellationKit */, ); productName = AsterismShareExtension; productReference = A10000000000000000000004 /* AsterismShareExtension.appex */;@@ -209,6 +216,7 @@ name = Asterism; packageProductDependencies = ( A10000000000000000000011 /* AsterismCore */,+ A1000000000000000000001B /* ConstellationKit */, ); productName = Asterism; productReference = D4A9C79430091593004199A5 /* Asterism.app */;@@ -233,6 +241,7 @@ name = AsterismTests; packageProductDependencies = ( A10000000000000000000013 /* AsterismCore */,+ A1000000000000000000001D /* ConstellationKit */, ); productName = AsterismTests; productReference = D4A9C7A530091595004199A5 /* AsterismTests.xctest */;@@ -890,6 +899,21 @@ package = A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */; productName = AsterismCore; };+ A1000000000000000000001B /* ConstellationKit */ = {+ isa = XCSwiftPackageProductDependency;+ package = A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */;+ productName = ConstellationKit;+ };+ A1000000000000000000001C /* ConstellationKit */ = {+ isa = XCSwiftPackageProductDependency;+ package = A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */;+ productName = ConstellationKit;+ };+ A1000000000000000000001D /* ConstellationKit */ = {+ isa = XCSwiftPackageProductDependency;+ package = A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */;+ productName = ConstellationKit;+ }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D4A9C78C30091593004199A5 /* Project object */;
diff --git a/Asterism/Asterism/AsterismApp.swift b/Asterism/Asterism/AsterismApp.swiftindex 6e4b381..13f21ac 100644--- a/Asterism/Asterism/AsterismApp.swift+++ b/Asterism/Asterism/AsterismApp.swift@@ -6,13 +6,37 @@ // import AsterismCore+import ConstellationKit import SwiftUI+import UIKit @main struct AsterismApp: App {+ init() {+ Self.applySerifNavigationTitles()+ }+ var body: some Scene { WindowGroup { ContentView() } }++ /// Requirement 8.3's serif navigation large titles.+ ///+ /// The `UINavigationBar` appearance proxy is the only API for a serif+ /// navigation title, and **only `largeTitleTextAttributes` is set** (Q27):+ /// in-process system controllers — `UIActivityViewController` above all,+ /// which the markdown export and backup share sheets present — draw+ /// standard-title bars, which that attribute does not touch. Setting+ /// `titleTextAttributes` too would leak the app's face into them.+ ///+ /// The font is scaled through `UIFontMetrics` (Q31, requirement 11.3), so+ /// the title honours Dynamic Type rather than pinning the style guide's+ /// 33 pt.+ private static func applySerifNavigationTitles() {+ var attributes = UINavigationBar.appearance().largeTitleTextAttributes ?? [:]+ attributes[.font] = AsterismTypography.scaledSerifLargeTitleFont()+ UINavigationBar.appearance().largeTitleTextAttributes = attributes+ } }
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex 33d44b8..16bbb0d 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import OSLog import SwiftUI @@ -17,7 +18,7 @@ struct ContentView: View { /// the re-teach route it offers is a sheet, and a sheet over a sheet cannot /// be presented from here. @State private var showingDiagnostics = false- @State private var showingTeachingForHostname: String?+ @State private var showingTeachingForHostname: PendingReteach? /// Req 9.2's route. Keyed by the *set*, because that is what the resolution /// is about — the surviving row is the app's to compute (Q21), so no record /// id would name the right thing.@@ -28,9 +29,11 @@ struct ContentView: View { /// Set by the Settings-hosted Check Library screen, which has to close /// Settings before a sheet of its own can present. @State private var pendingResolveSet: DuplicateSetKey?- /// Set by the Settings-hosted diagnosis screen, which has to close Settings- /// before the teaching sheet can present.- @State private var pendingReteachHostname: String?+ /// Set by the Settings-hosted diagnosis and Sites screens, which have to+ /// close Settings before the teaching sheet can present. Carries the+ /// articles-conversion flag, because only one of those two routes may set it+ /// (Decision 3).+ @State private var pendingReteachHostname: PendingReteach? enum AppTab { case recent@@ -209,10 +212,12 @@ struct ContentView: View { } .navigationDestination(isPresented: $showingDiagnostics) { if let diagnosticsModel = model.libraryDiagnosticsModel(- onReteach: { hostname in showingTeachingForHostname = hostname },+ onReteach: { hostname in+ showingTeachingForHostname = PendingReteach(hostname: hostname)+ }, onResolveDuplicate: route(toResolve:) ) {- LibraryDiagnosticsView(model: diagnosticsModel)+ LibraryDiagnosticsView(model: diagnosticsModel, showsSky: true) } } }@@ -234,7 +239,8 @@ struct ContentView: View { if let detailModel = model.workDetailModel(for: workID) { WorkDetailView( model: detailModel,- onResolveDuplicate: resolveRoute(forWork: workID))+ onResolveDuplicate: resolveRoute(forWork: workID),+ exportModel: model.markdownExportModel(forWork: workID)) } } .navigationDestination(item: $selectedWorksEntryID) { entryID in@@ -244,6 +250,10 @@ struct ContentView: View { } .accessibilityIdentifier("tab-works") }+ // Requirement 9.3: the native tab bar is already the floating glass+ // capsule on iOS 26; the active item's cyan is the one token it takes.+ // The dark-mode icon glow is the accepted Q16 deviation.+ .tint(AsterismColors.cyan) .sheet(isPresented: $showingNewWork) { if let newWorkModel = model.newWorkModel() { NewWorkView(model: newWorkModel)@@ -278,7 +288,12 @@ struct ContentView: View { // still says why the fields are off; the way in is the Works tab pill. .sheet(item: $mergingWorkID) { presented in if let detailModel = model.workDetailModel(for: presented.id) {- NavigationStack { WorkDetailView(model: detailModel) }+ NavigationStack {+ WorkDetailView(+ model: detailModel,+ exportModel: model.markdownExportModel(forWork: presented.id),+ showsSky: false)+ } } } .sheet(isPresented: $showingSettings, onDismiss: presentPendingResolution) {@@ -292,7 +307,7 @@ struct ContentView: View { // here, and this view is covered while Settings is up. diagnosticsModel: model.libraryDiagnosticsModel( onReteach: { hostname in- pendingReteachHostname = hostname+ pendingReteachHostname = PendingReteach(hostname: hostname) showingSettings = false }, onResolveDuplicate: { setKey in@@ -301,7 +316,19 @@ struct ContentView: View { } ), syncModel: model.settingsSyncModel(),- interruptedImportNotice: model.interruptedImportNotice+ interruptedImportNotice: model.interruptedImportNotice,+ sitesModel: model.sitesListModel(),+ // Req 6.2 rides the same pending-route plumbing Library+ // Check uses. The flag is Decision 3's one exception,+ // and this is the only route that ever sets it.+ siteDetailModel: { site in+ model.siteDetailModel(for: site) { hostname, permits in+ pendingReteachHostname = PendingReteach(+ hostname: hostname,+ permitsArticlesConversion: permits)+ showingSettings = false+ }+ } ) .toolbar { ToolbarItem(placement: .confirmationAction) {@@ -328,17 +355,13 @@ struct ContentView: View { ComposedTeachingContainerView(model: teachModel) } }- // The diagnosis screen's re-teach route (Req 4.5). Keyed by hostname- // rather than Entry: a diagnosed Site's rows carry no action, so there is- // no pill to enter it from.- .sheet(- isPresented: Binding(- get: { showingTeachingForHostname != nil },- set: { if !$0 { showingTeachingForHostname = nil } }- )- ) {- if let hostname = showingTeachingForHostname,- let teachModel = model.composedTeachingModel(forHostname: hostname) {+ // The diagnosis and Sites screens' re-teach route (Reqs 4.5, 6.2).+ // Keyed by hostname rather than Entry: a diagnosed Site's rows carry no+ // action, so there is no pill to enter it from.+ .sheet(item: $showingTeachingForHostname) { pending in+ if let teachModel = model.composedTeachingModel(+ forHostname: pending.hostname,+ permitsArticlesConversion: pending.permitsArticlesConversion) { ComposedTeachingContainerView(model: teachModel) } }@@ -348,9 +371,9 @@ struct ContentView: View { /// actually gone. Presenting both in the same turn drops the second /// presentation. private func presentPendingReteach() {- guard let hostname = pendingReteachHostname else { return }+ guard let pending = pendingReteachHostname else { return } pendingReteachHostname = nil- showingTeachingForHostname = hostname+ showingTeachingForHostname = pending } private func presentPendingResolution() {@@ -393,7 +416,8 @@ struct ContentView: View { onMoveTo: { showingMoveTo = entryID }, onResolveDuplicate: model.recentPresentation.duplicateWorkload .item(for: entryID, type: .entry)- .map { item in { route(toResolve: item.key) } }+ .map { item in { route(toResolve: item.key) } },+ exportModel: model.markdownExportModel(forEntry: entryID) ) } }@@ -410,6 +434,17 @@ private struct PresentedWork: Identifiable, Equatable { let id: UUID } +/// A re-teach route waiting for Settings to close, or presented directly.+///+/// The flag travels with the hostname rather than beside it so a route can+/// never be dispatched having lost it — Decision 3's gate is only as good as+/// the one place that sets it.+private struct PendingReteach: Identifiable, Equatable {+ let hostname: String+ var permitsArticlesConversion = false+ var id: String { hostname }+}+ // An explicit configuration, not the production initializer (Q19): a preview // agent's `Bundle.main` is not the app bundle and no build check governs it, so // production resolution here could hit the Req 2.3 trap as a false positive.
diff --git a/Asterism/Asterism/Style/AsterismStyle.swift b/Asterism/Asterism/Style/AsterismStyle.swiftdeleted file mode 100644index 1cece8b..0000000--- a/Asterism/Asterism/Style/AsterismStyle.swift+++ /dev/null@@ -1,54 +0,0 @@-import SwiftUI--/// Semantic color tokens for the Constellation design language.-/// See docs/asterism-style-guide.md §2 for oklch definitions.-enum AsterismColors {- // MARK: - Accent: Cyan (primary/positive)-- static let cyanDark = Color(red: 0.45, green: 0.82, blue: 0.92)- static let cyanLight = Color(red: 0.10, green: 0.45, blue: 0.58)- static var cyan: Color { cyanDark }-- // MARK: - Accent: Violet (secondary/negative)-- static let violetDark = Color(red: 0.78, green: 0.52, blue: 0.92)- static let violetLight = Color(red: 0.42, green: 0.18, blue: 0.58)- static var violet: Color { violetDark }-- // MARK: - Accent: Amber (teaching only)-- static let amberDark = Color(red: 0.92, green: 0.78, blue: 0.35)- static let amberLight = Color(red: 0.58, green: 0.45, blue: 0.12)-- // MARK: - Neutrals-- static let primaryTextDark = Color(red: 0.91, green: 0.93, blue: 0.96)- static let primaryTextLight = Color(red: 0.11, green: 0.12, blue: 0.17)- static let secondaryTextDark = Color(red: 0.56, green: 0.59, blue: 0.68)- static let secondaryTextLight = Color(red: 0.40, green: 0.43, blue: 0.51)- static let noteTextDark = Color(red: 0.71, green: 0.74, blue: 0.82)- static let noteTextLight = Color(red: 0.24, green: 0.26, blue: 0.34)-- // MARK: - Opaque fallbacks (Reduce Transparency)-- static let opaqueCardDark = Color(red: 0.07, green: 0.09, blue: 0.16)- static let opaqueCardLight = Color(red: 0.95, green: 0.95, blue: 0.97)-}--/// Semantic typography helpers.-enum AsterismTypography {- /// Serif font for work titles and screen titles (New York / SF Serif).- static func serifTitle(_ size: CGFloat, weight: Font.Weight = .medium) -> Font {- .system(size: size, weight: weight, design: .serif)- }-- /// Section header style: 11pt, bold, uppercase.- static let sectionHeader: Font = .system(size: 11, weight: .bold, design: .default)-}--/// Common shape/sizing constants.-enum AsterismLayout {- static let cardRadius: CGFloat = 20- static let buttonRadius: CGFloat = 21- static let minHitTarget: CGFloat = 44-}
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 21dd792..a980632 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -465,7 +465,13 @@ public final class AppLibraryModel { /// Returns nil when no Entry exists on the hostname: the composed surface is /// entered from an Entry, so a diagnosed Site with nothing captured from it /// has nothing to teach against. The screen still lists the diagnosis.- public func composedTeachingModel(forHostname hostname: String) -> ComposedTeachingViewModel? {+ /// `permitsArticlesConversion` is Decision 3's one exception: only the+ /// Sites screen's re-teach route sets it, and only with it will the+ /// projection and commit accept an articles Site.+ public func composedTeachingModel(+ forHostname hostname: String,+ permitsArticlesConversion: Bool = false+ ) -> ComposedTeachingViewModel? { guard capabilities.supportsSegmentTeaching, let repo = repository, let row = recentPresentation.allRows.first(where: { $0.hostname == hostname })@@ -477,12 +483,47 @@ public final class AppLibraryModel { library: repo, capabilities: capabilities, entryContext: .titleFocused,+ permitsArticlesConversion: permitsArticlesConversion, onMutation: { [weak self] in await self?.refreshDiagnosesAndSnapshots() } ) } + /// Whether the composed teaching surface can be entered for a hostname —+ /// the same question `composedTeachingModel(forHostname:)` answers by+ /// returning nil, asked without building a model to throw away.+ ///+ /// The Sites screen needs the answer *before* the reader taps, so it can+ /// disable the action and say why (Req 6.2).+ public func canComposeTeaching(forHostname hostname: String) -> Bool {+ capabilities.supportsSegmentTeaching+ && repository != nil+ && recentPresentation.allRows.contains { $0.hostname == hostname }+ }++ /// The Sites list (Req 6.1).+ public func sitesListModel() -> SitesListModel? {+ guard let repo = repository else { return nil }+ return SitesListModel(library: repo)+ }++ /// One site's screen (Reqs 6.2–6.4). The caller supplies the re-teach route+ /// because navigation is its concern — the Settings host has to dismiss+ /// itself before the teaching sheet can present.+ public func siteDetailModel(+ for site: SiteSnapshot,+ onReteach: @escaping @MainActor (String, Bool) -> Void+ ) -> SiteDetailModel? {+ guard let repo = repository else { return nil }+ return SiteDetailModel(+ site: site,+ library: repo,+ canReteach: canComposeTeaching(forHostname: site.hostname),+ onReteach: onReteach,+ onMutation: { [weak self] in await self?.refreshDiagnosesAndSnapshots() })+ }+ /// The diagnosis surface's model (Req 4.1, 4.2, and Req 9.3's duplicate /// rows). The caller supplies the re-teach route because navigation is its /// concern, not the model's.@@ -932,6 +973,29 @@ public final class AppLibraryModel { return SettingsBackupModel(exporter: exporter) } + /// The per-entry markdown export model (Req 1.5), staged beside the backup+ /// exporter's own directory but in its own so the two scavengers never see+ /// each other's files.+ public func markdownExportModel(forEntry entryID: UUID) -> MarkdownExportModel? {+ markdownExportModel(subject: .entry(entryID))+ }++ /// The per-work markdown export model (Req 2.4).+ public func markdownExportModel(forWork workID: UUID) -> MarkdownExportModel? {+ markdownExportModel(subject: .work(workID))+ }++ private func markdownExportModel(+ subject: MarkdownExportModel.Subject+ ) -> MarkdownExportModel? {+ guard let repo = repository, let config = resolvedConfiguration else { return nil }+ return MarkdownExportModel(+ subject: subject,+ library: repo,+ stagingDirectory: MarkdownExportModel.stagingDirectory(+ inRoot: config.rootDirectory))+ }+ /// Provides a settings backup import model for restoring into a ready library. public func settingsBackupImportModel() -> SettingsBackupImportModel? { guard let repo = repository else { return nil }
diff --git a/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift b/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swiftindex 061ee7e..d4dd777 100644--- a/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift+++ b/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift@@ -116,6 +116,12 @@ public final class ComposedTeachingViewModel { private let library: any LibraryProviding private let capabilities: AsterismCapabilities private let entryContext: EntryContext+ /// Decision 3: whether this surface may re-teach an **articles** site.+ ///+ /// False everywhere but the Sites screen's route. Both teaching gates refuse+ /// an articles site without it, which is what keeps an inbox-driven teach+ /// from converting one by accident.+ private let permitsArticlesConversion: Bool private let onMutation: (@Sendable () async -> Void)? private var contract: ComposedTeachingContract?@@ -247,12 +253,14 @@ public final class ComposedTeachingViewModel { library: any LibraryProviding, capabilities: AsterismCapabilities = .m4, entryContext: EntryContext = .titleFocused,+ permitsArticlesConversion: Bool = false, onMutation: (@Sendable () async -> Void)? = nil ) { self.entry = entry self.library = library self.capabilities = capabilities self.entryContext = entryContext+ self.permitsArticlesConversion = permitsArticlesConversion self.onMutation = onMutation } @@ -706,7 +714,8 @@ public final class ComposedTeachingViewModel { trimPrefix: title.trimPrefix, trimSuffix: title.trimSuffix, urlDefinition: urlRuleDefinition,- acknowledgeUnsettled: acknowledgedUnsettled)+ acknowledgeUnsettled: acknowledgedUnsettled,+ permitsArticlesConversion: permitsArticlesConversion) } // MARK: - Projection@@ -717,7 +726,8 @@ public final class ComposedTeachingViewModel { ) async throws -> ComposedTeachingContract { let request = ComposedTeachingRequest( titleDefinition: titleDefinition, trimPrefix: trimPrefix, trimSuffix: trimSuffix,- urlDefinition: urlDefinition, acknowledgeUnsettled: acknowledge)+ urlDefinition: urlDefinition, acknowledgeUnsettled: acknowledge,+ permitsArticlesConversion: permitsArticlesConversion) return try await library.projectComposedTeaching(hostname: entry.hostname, request: request) }
diff --git a/Asterism/Asterism/ViewModels/EntryDetailModel.swift b/Asterism/Asterism/ViewModels/EntryDetailModel.swiftindex c707366..2cff52f 100644--- a/Asterism/Asterism/ViewModels/EntryDetailModel.swift+++ b/Asterism/Asterism/ViewModels/EntryDetailModel.swift@@ -196,13 +196,24 @@ public final class EntryDetailModel { var disclosedIDs: Set<VariantID> { Set(variants.map(\.id)) } /// One line per copy, in the order the sheet would present them.- public var lines: [String] {- variants.map { variant in- let note = variant.content.note.isEmpty- ? "(no note)" : variant.content.note- let rating = variant.content.rating.map { $0 == .up ? " ▲" : " ▼" } ?? ""- return note + rating- }+ public var lines: [String] { EntryDetailModel.disclosureLines(variants) }+ }++ /// How a torn Entry group's copies are worded in a disclosure.+ ///+ /// Work deletion discloses the same copies from its own alert+ /// (`WorkDetailModel.WorkDeleteDisclosure`), and the two alerts have to read+ /// identically — a reader shown a copy one way here and another way there+ /// cannot tell they are the same copy. One spelling, as with+ /// ``conflictMessage(_:)``.+ static func disclosureLines(+ _ variants: [AuthoredVariant<EntryAuthoredContent>]+ ) -> [String] {+ variants.map { variant in+ let note = variant.content.note.isEmpty+ ? "(no note)" : variant.content.note+ let rating = variant.content.rating.map { $0 == .up ? " ▲" : " ▼" } ?? ""+ return note + rating } }
diff --git a/Asterism/Asterism/ViewModels/MarkdownExportModel.swift b/Asterism/Asterism/ViewModels/MarkdownExportModel.swiftnew file mode 100644index 0000000..2bef439--- /dev/null+++ b/Asterism/Asterism/ViewModels/MarkdownExportModel.swift@@ -0,0 +1,152 @@+import AsterismCore+import Foundation+import OSLog++/// Drives a per-entry or per-work markdown export (Reqs 1.5, 2.4).+///+/// The third layer of the export split (Q17): the repository resolves the input,+/// `MarkdownExport` renders it, and this model stages the result as a `.md` file+/// and drives the share sheet. Sibling of `SettingsBackupModel` and deliberately+/// the same shape — same `.idle/.exporting/.sharing/.failed` machine, same+/// privacy-safe message discipline, same cleanup-on-dismiss — because the reader+/// meets both through the identical `ShareSheet`.+@MainActor @Observable+public final class MarkdownExportModel {+ private static let logger = Logger(+ subsystem: "me.nore.ig.Asterism", category: "MarkdownExportModel")++ /// What is being exported. One model per invocation, so the subject is+ /// fixed at construction.+ public enum Subject: Equatable, Sendable {+ case entry(UUID)+ case work(UUID)+ }++ public enum State: Equatable, Sendable {+ case idle+ case exporting+ case sharing+ case failed+ }++ public private(set) var state: State = .idle+ public private(set) var exportedFileURL: URL?+ public private(set) var errorMessage: String?++ private let subject: Subject+ private let library: any LibraryProviding+ private let stagingDirectory: URL+ private let locale: Locale+ private var isExporting = false++ /// Creates the model and clears anything a previous session abandoned.+ public init(+ subject: Subject,+ library: any LibraryProviding,+ stagingDirectory: URL,+ locale: Locale = .current+ ) {+ self.subject = subject+ self.library = library+ self.stagingDirectory = stagingDirectory+ self.locale = locale+ scavengeStaleFiles()+ }++ /// Where exports are staged, given the library's root (design: under+ /// `Library/Caches`, beside the backup exporter's own directory).+ public nonisolated static func stagingDirectory(inRoot rootDirectory: URL) -> URL {+ rootDirectory.appending(path: "Library/Caches/MarkdownExports")+ }++ // MARK: - Export++ /// Reads the input, renders it, stages the file, and hands it to the share+ /// sheet. Suppresses a second submission while one is in flight.+ public func startExport() async {+ guard !isExporting else { return }+ isExporting = true+ state = .exporting+ errorMessage = nil+ exportedFileURL = nil++ do {+ let (document, filename) = try await renderDocument()+ exportedFileURL = try stage(document, as: filename)+ state = .sharing+ Self.logger.debug("Markdown export staged")+ } catch {+ state = .failed+ errorMessage = Self.privacySafeMessage(for: error)+ Self.logger.error(+ "Markdown export failed: \(Self.diagnosticCategory(for: error), privacy: .public)")+ }++ isExporting = false+ }++ /// Rendering never fails — the inputs are total and escaping is total — so+ /// the only thing that can throw here is the read.+ private func renderDocument() async throws -> (document: String, filename: String) {+ switch subject {+ case .entry(let id):+ let input = try await library.entryExportInput(entryID: id, locale: locale)+ return (MarkdownExport.renderEntry(input), MarkdownExport.filename(for: input))+ case .work(let id):+ let input = try await library.workExportInput(workID: id, locale: locale)+ return (MarkdownExport.renderWork(input), MarkdownExport.filename(for: input))+ }+ }++ private func stage(_ document: String, as filename: String) throws -> URL {+ try FileManager.default.createDirectory(+ at: stagingDirectory, withIntermediateDirectories: true)+ let fileURL = stagingDirectory.appending(path: filename)+ try ExportStaging.write(Data(document.utf8), to: fileURL)+ return fileURL+ }++ // MARK: - Share lifecycle++ public func handleShareCompletion() { cleanupAndReset() }++ public func handleShareCancellation() { cleanupAndReset() }++ private func cleanupAndReset() {+ if let exportedFileURL {+ try? FileManager.default.removeItem(at: exportedFileURL)+ }+ exportedFileURL = nil+ state = .idle+ }++ /// Removes abandoned exports older than 24 hours.+ ///+ /// By directory rather than by filename prefix, which is what the backup+ /// exporter keys on: markdown filenames come from the reader's own titles+ /// (Q11) and share no prefix, and this directory holds nothing else (Q40).+ private func scavengeStaleFiles() {+ ExportStaging.scavenge(directory: stagingDirectory) { $0.pathExtension == "md" }+ }++ // MARK: - Messages++ /// Never a title, a URL, a path, or an identifier — the same rule+ /// `SettingsBackupModel` holds to, for the same reason: an export failure is+ /// reported on screen and must not be a way for content to leak out of it.+ private static func privacySafeMessage(for error: Error) -> String {+ if let repositoryError = error as? LibraryRepositoryError,+ case .recordNotFound = repositoryError {+ return "This note is no longer in your library, so there was nothing to export."+ }+ return "The markdown export could not be prepared. Please try again."+ }++ private static func diagnosticCategory(for error: Error) -> String {+ switch error {+ case let error as LibraryRepositoryError: "repository: \(error)"+ case let error as CocoaError: "staging: \(error.code.rawValue)"+ default: "unknown: \(type(of: error))"+ }+ }+}
diff --git a/Asterism/Asterism/ViewModels/SearchFilters.swift b/Asterism/Asterism/ViewModels/SearchFilters.swiftnew file mode 100644index 0000000..e7ac12d--- /dev/null+++ b/Asterism/Asterism/ViewModels/SearchFilters.swift@@ -0,0 +1,83 @@+import AsterismCore+import Foundation++// Search on both tabs (Reqs 3, 4).+//+// Pure in-memory filtering over the presentation types the two tabs already+// render (Q19): both are functions of one repository read that carries every+// searchable field, and diacritic-insensitive matching could not be a store+// predicate anyway. Structs rather than view code so the requirement is+// testable without a view.++/// Case- and diacritic-insensitive containment, the one matching rule both+/// filters use (Reqs 3.1, 4.1).+private func contains(_ haystack: String?, _ needle: String) -> Bool {+ guard let haystack, !haystack.isEmpty else { return false }+ return haystack.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil+}++/// The Recent tab's search (Req 3).+///+/// Takes the **already banner-filtered** groups and narrows them further, which+/// is what makes the two conditions AND rather than replace one another (3.2,+/// Q6). Elsewhere lines are not this type's business: they carry no titles to+/// match and stay visible under an active query (Q13), so the view renders them+/// outside the filtered rows.+struct RecentSearchFilter: Equatable {+ let query: String++ private var needle: String {+ query.trimmingCharacters(in: .whitespacesAndNewlines)+ }++ /// Whether the query narrows anything. A blank query restores the list to+ /// its prior state, banner filter included (3.5).+ var isActive: Bool { !needle.isEmpty }++ /// Day groups are preserved; a group left with no rows is dropped rather+ /// than rendered as an empty header (3.4).+ func apply(to groups: [RecentPresentationGroup]) -> [RecentPresentationGroup] {+ guard isActive else { return groups }+ let needle = self.needle+ return groups.compactMap { group in+ let rows = group.rows.filter { Self.matches($0, needle: needle) }+ guard !rows.isEmpty else { return nil }+ return RecentPresentationGroup(day: group.day, rows: rows)+ }+ }++ /// Note text, chapter title, and work title (3.1). An entry with no chapter+ /// title matches on its cleaned capture title **in place of** it (3.3) —+ /// the row's `captureTitle` is already the cleaned display value, so a+ /// junk-suffixed raw title is never the haystack.+ private static func matches(_ row: RecentPresentationRow, needle: String) -> Bool {+ contains(row.note, needle)+ || contains(row.chapterTitle ?? row.captureTitle, needle)+ || contains(row.workDisplayTitle, needle)+ }+}++/// The Works tab's search (Req 4).+///+/// Work display titles only. While a query is active the unattached group is+/// hidden outright (4.2, Q12): those entries have no work title to match, and+/// Recent's search is where their content is searchable.+struct WorksSearchFilter: Equatable {+ let query: String++ private var needle: String {+ query.trimmingCharacters(in: .whitespacesAndNewlines)+ }++ var isActive: Bool { !needle.isEmpty }++ /// Filtering only — never reordering, so the list's existing order,+ /// including empty works sinking, survives (4.1).+ func apply(to snapshot: WorksSnapshot) -> WorksSnapshot {+ guard isActive else { return snapshot }+ let needle = self.needle+ return WorksSnapshot(+ works: snapshot.works.filter { contains($0.displayTitle, needle) },+ unattachedEntries: [])+ }+}
diff --git a/Asterism/Asterism/ViewModels/SitesModels.swift b/Asterism/Asterism/ViewModels/SitesModels.swiftnew file mode 100644index 0000000..1b2d682--- /dev/null+++ b/Asterism/Asterism/ViewModels/SitesModels.swift@@ -0,0 +1,241 @@+import AsterismCore+import Foundation+import Observation+import OSLog++// The Sites settings screen's two models (Req 6).+//+// Every sentence the screen shows is built here, following the+// `LibraryDiagnosticsModel` convention: the views choose rows and styling, never+// wording. That is also what makes the screen's language testable.++/// The list of every site the library knows (Req 6.1).+@MainActor @Observable+public final class SitesListModel {+ private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "SitesListModel")++ public enum State: Equatable, Sendable {+ case loading+ case ready+ case error(message: String)+ }++ /// One site, as the list presents it. Keyed by hostname, which is what the+ /// read returns one row per (Q24).+ public struct Row: Identifiable, Equatable, Sendable {+ public let site: SiteSnapshot++ public var id: String { site.hostname }+ public var hostname: String { site.hostname }+ public var displayName: String { site.displayName }+ public var modeLabel: String { SiteModePresentation.label(for: site.mode) }+ /// Whether the name line adds anything the hostname line does not.+ public var showsHostnameSeparately: Bool { site.displayName != site.hostname }+ }++ public private(set) var state: State = .loading+ public private(set) var rows: [Row] = []++ /// Why the list is empty. A library with no sites has captured nothing yet,+ /// which is a different thing from a failed read.+ public let emptyMessage =+ "No sites yet. Share a page to Asterism and the site it came from appears here."++ private let library: any LibraryProviding++ public init(library: any LibraryProviding) {+ self.library = library+ }++ public func load() async {+ state = .loading+ do {+ rows = try await library.sites().map(Row.init(site:))+ state = .ready+ } catch {+ rows = []+ state = .error(message: error.localizedDescription)+ Self.logger.error("Sites read failed: \(String(describing: error), privacy: .public)")+ }+ }+}++/// One site's screen: what mode it is in, the re-teach route, and — for a site+/// not already in articles mode — the one-way switch (Reqs 6.2–6.4).+@MainActor @Observable+public final class SiteDetailModel {+ private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "SiteDetailModel")++ /// What the articles confirmation is offering, built from the projection so+ /// the count it names is the one the commit will apply.+ ///+ /// The contract rides **on the presented value**, not beside it in the+ /// model, for the reason `WorkDetailModel.WorkDeletionPrompt` records:+ /// SwiftUI runs a dialog's `isPresented` setter — the dismissal, which is+ /// `cancelArticlesSwitch()` — *before* it runs the tapped button's action,+ /// so a confirm that re-read a model property found it already cleared and+ /// committed nothing, silently.+ public struct ArticlesPrompt: Equatable, Sendable {+ public let contract: ArticlesContract+ public let entryCount: Int++ public var message: String {+ let subject = Pluralisation.count(+ entryCount, "note from this site becomes", "notes from this site become")+ return "\(subject) independent articles: they stop being chapters of a work, "+ + "and the site's title and URL rules are retired. This cannot be undone from "+ + "here — the way back is to teach the site again."+ }+ }++ public let site: SiteSnapshot+ public private(set) var articlesPrompt: ArticlesPrompt?+ public private(set) var errorMessage: String?+ public private(set) var isSubmitting = false+ /// Set once the switch commits, so the screen can leave.+ public private(set) var didSwitchToArticles = false++ private let library: any LibraryProviding+ private let onReteach: @MainActor (String, Bool) -> Void+ private let onMutation: @Sendable () async -> Void++ /// Whether a re-teach can be entered at all.+ ///+ /// The composed teaching surface is entered from an Entry, so a site with+ /// nothing captured from it has nothing to teach against — which is exactly+ /// when `AppLibraryModel.composedTeachingModel(forHostname:)` returns nil.+ /// The host answers that question; this model only reports it.+ public let canReteach: Bool++ public init(+ site: SiteSnapshot,+ library: any LibraryProviding,+ canReteach: Bool,+ onReteach: @escaping @MainActor (String, Bool) -> Void,+ onMutation: @escaping @Sendable () async -> Void+ ) {+ self.site = site+ self.library = library+ self.canReteach = canReteach+ self.onReteach = onReteach+ self.onMutation = onMutation+ }++ // MARK: - Wording++ public var modeLabel: String { SiteModePresentation.label(for: site.mode) }++ public var modeExplanation: String {+ switch site.mode {+ case .untaught:+ "Asterism has not been taught how this site names its pages, so its notes keep "+ + "their page titles."+ case .taught:+ "Asterism reads a work and a chapter out of this site's page titles."+ case .articles:+ "Every page from this site stands on its own. Teaching the site again returns it "+ + "to reading works and chapters."+ }+ }++ public var reteachLabel: String {+ site.mode == .untaught ? "Teach site" : "Re-teach site"+ }++ /// The line beside a disabled re-teach. Nil when the action is available —+ /// an explanation for something that works is noise.+ public var reteachUnavailableMessage: String? {+ canReteach+ ? nil+ : "Asterism has no notes from this site yet, so there is nothing to teach it with."+ }++ /// Req 6.3: articles mode is one-way, so a site already in it is not offered+ /// the switch; its way out is the re-teach above (Decision 3).+ public var canSwitchToArticles: Bool { site.mode != .articles }++ // MARK: - Re-teach (Req 6.2, 6.3)++ /// Routes to the composed teaching surface.+ ///+ /// The flag is what makes this screen the one place an articles site can be+ /// re-taught (Decision 3): every other entry point leaves it false, and the+ /// projection and commit refuse an articles site without it.+ public func reteach() {+ guard canReteach else { return }+ onReteach(site.hostname, site.mode == .articles)+ }++ // MARK: - Articles switch (Req 6.3, 6.4)++ /// Projects the switch and raises its confirmation. Nothing is written.+ public func requestArticlesSwitch() async {+ guard canSwitchToArticles, !isSubmitting else { return }+ errorMessage = nil+ do {+ present(try await library.projectArticles(+ hostname: site.hostname, junkSuffixRule: nil))+ } catch {+ errorMessage = error.localizedDescription+ Self.logger.error(+ "Articles projection failed: \(String(describing: error), privacy: .public)")+ }+ }++ public func cancelArticlesSwitch() {+ articlesPrompt = nil+ }++ /// Commits the projected contract — the same one teach mode commits, so the+ /// retroactive semantics are identical by construction (Req 6.4).+ ///+ /// The contract comes from the prompt the dialog rendered, never from this+ /// model: SwiftUI has already run the dismissal by the time this action+ /// fires (see `ArticlesPrompt`).+ public func confirmArticlesSwitch(_ prompt: ArticlesPrompt) async {+ guard !isSubmitting else { return }+ isSubmitting = true+ errorMessage = nil+ defer { isSubmitting = false }++ do {+ switch try await library.commitArticles(prompt.contract) {+ case .committed:+ articlesPrompt = nil+ didSwitchToArticles = true+ await onMutation()+ case .refreshed(let fresh):+ // The confirmation named a count; something moved under it, so+ // the reader answers the current one rather than the old one.+ present(fresh)+ errorMessage =+ "This site changed while you were deciding. Check what will happen and "+ + "confirm again."+ case .invalidated(let reason):+ errorMessage = reason+ }+ } catch {+ errorMessage = error.localizedDescription+ Self.logger.error(+ "Articles commit failed: \(String(describing: error), privacy: .public)")+ }+ }++ private func present(_ contract: ArticlesContract) {+ articlesPrompt = ArticlesPrompt(+ contract: contract,+ entryCount: contract.outcome.plan.entryProjections.count)+ }+}++/// The one place a site mode is turned into words, so the list row and the+/// detail screen cannot describe the same site differently.+enum SiteModePresentation {+ static func label(for mode: SiteMode) -> String {+ switch mode {+ case .untaught: "Not taught"+ case .taught: "Taught"+ case .articles: "Articles"+ }+ }+}
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex a54a25f..e0adda0 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import Foundation import Observation import OSLog@@ -15,7 +16,20 @@ public final class WorkDetailModel { } public private(set) var state: State = .loading- public private(set) var work: WorkSnapshot?+ /// The whole §6 read (Reqs 5.1–5.4), from one call so the counts, the+ /// last-noted URL and the chapter rows describe one moment of the store.+ public private(set) var presentation: WorkDetailPresentation?+ /// The Work the presentation describes. Read off the presentation rather+ /// than stored beside it: two fields set from one load are two fields that+ /// can disagree, and every other §6 read on this model is already derived.+ public var work: WorkSnapshot? { presentation?.work }++ /// Req 5.2's three counts, over logical records.+ public var pulse: RatingPulse { presentation?.pulse ?? RatingPulse(notes: 0, up: 0, down: 0) }+ /// Req 5.3: nil hides the action; the repository decides, not the screen.+ public var lastNotedURLString: String? { presentation?.lastNotedURLString }+ /// Req 5.4, newest-first with cleaned display titles.+ public var chapterRows: [WorkChapterRow] { presentation?.chapterRows ?? [] } /// Req 2.8, mirrored from Entry detail: a torn Work's authored fields are /// read-only until its resolution. The repository refuses the write either@@ -68,8 +82,9 @@ public final class WorkDetailModel { public func load() async { state = .loading do {- let snapshot = try await library.work(id: workID)- work = snapshot+ let detail = try await library.workDetail(id: workID)+ presentation = detail+ let snapshot = detail.work draftTitle = snapshot.displayTitle draftType = snapshot.type draftTags = snapshot.genreTags@@ -159,6 +174,20 @@ public final class WorkDetailModel { ) } + /// Whether the drafts differ from the loaded snapshot.+ ///+ /// §6 puts the title, type, tags and notes on the screen itself, so Save is+ /// a toolbar confirmation shown only while there is something to save — a+ /// permanently-visible Save on a presentation-first screen reads as an+ /// unfinished form.+ public var hasUnsavedChanges: Bool {+ guard let work else { return false }+ return draftTitle != work.displayTitle+ || draftType != work.type+ || draftTags != work.genreTags+ || draftNotes != work.genericNotes+ }+ /// Commits metadata edit. Suppresses duplicate submissions. public func save() async { guard !isSubmitting else { return }@@ -204,6 +233,194 @@ public final class WorkDetailModel { isSubmitting = false } + // MARK: - Deletion (Req 7)++ /// What the confirmation dialog is offering, or nil when none is up.+ ///+ /// Built from a **projection**, not from the loaded snapshot: the count the+ /// dialog names is the one the commit re-verifies (Q28), and the loaded+ /// snapshot's entry list describes an earlier moment.+ public struct WorkDeletionPrompt: Identifiable, Equatable, Sendable {+ public let id: UUID+ /// The projection the dialog was built from, **carried on the presented+ /// value rather than held beside it in the model**.+ ///+ /// SwiftUI runs a dialog's `isPresented` setter — the dismissal, which+ /// is `cancelDelete()` — *before* it runs the tapped button's action, so+ /// a `chooseDeletion` that re-read a model property found it already+ /// cleared and deleted nothing, silently. Entry detail records the same+ /// hazard on `EntryDetailModel.confirmDelete(_:)`. Taking the contract+ /// from the prompt is both the fix and the stronger statement: the count+ /// the message names and the contract the commit takes are one value.+ public let contract: WorkDeletionContract++ /// How many logical notes the work owns outright — shared groups are+ /// repointed rather than deleted (Q25, Q35), so they are not counted.+ public var entryCount: Int { contract.entryGroupIDs.count }++ /// The sentence above the two choices. Wording lives here, per the+ /// house convention that models own text and views own layout.+ public var message: String {+ guard entryCount > 0 else {+ return "This work has no notes. Deleting it cannot be undone."+ }+ return "This work has \(Pluralisation.count(entryCount, "note", "notes")). "+ + "Choose what happens to \(entryCount == 1 ? "it" : "them") — "+ + "this cannot be undone."+ }+ }++ /// What a torn-group disclosure alert is showing (Req 7.2).+ ///+ /// **This value is the disclosure**, exactly as on entry deletion: the+ /// variants travel from the alert that rendered them into the commit, so+ /// what the commit re-verifies is what the reader was shown. It carries the+ /// disposition too, because the reader has already chosen one and the alert+ /// is the second half of that single answer.+ public struct WorkDeleteDisclosure: Identifiable, Equatable, Sendable {+ public let id: UUID+ public let disposition: WorkDeletionDisposition+ /// The contract travels with the disclosure for the same reason it+ /// travels with the prompt: the alert's dismissal runs before its+ /// button's action, so nothing the commit needs may live in the model.+ public let contract: WorkDeletionContract+ public let variants: [AuthoredVariant<EntryAuthoredContent>]++ var disclosedIDs: Set<VariantID> { Set(variants.map(\.id)) }++ /// One line per copy, in the order the resolution sheet would show them+ /// — worded by `EntryDetailModel`, which owns the wording for both+ /// alerts, as it already owns `conflictMessage`.+ public var lines: [String] { EntryDetailModel.disclosureLines(variants) }+ }++ public private(set) var deletionPrompt: WorkDeletionPrompt?+ public private(set) var deleteDisclosure: WorkDeleteDisclosure?+ /// Whether the work is gone, which is the screen's cue to leave.+ public private(set) var isDeleted = false++ /// Req 7.1's entry point: project, then offer the two dispositions.+ public func requestDelete() async {+ errorMessage = nil+ await projectDeletion()+ }++ /// The projection itself, which the stale-disclosure path re-runs. It leaves+ /// `errorMessage` alone: on that path the message explaining the+ /// re-presentation is the one thing the reader needs to keep.+ private func projectDeletion() async {+ do {+ present(try await library.projectWorkDeletion(workID: workID))+ } catch {+ errorMessage = error.localizedDescription+ state = .error(message: error.localizedDescription)+ Self.logger.error(+ "Work deletion projection failed: \(String(describing: error), privacy: .public)")+ }+ }++ /// The dialog's dismissal. It may clear anything it likes: every button+ /// action takes what it needs as a parameter, so nothing here can strand a+ /// commit that is about to run.+ public func cancelDelete() {+ deletionPrompt = nil+ deleteDisclosure = nil+ }++ /// The reader's choice of disposition, over the prompt the dialog rendered.+ /// A torn owned group stops here and discloses; everything else commits.+ public func chooseDeletion(+ _ prompt: WorkDeletionPrompt, disposition: WorkDeletionDisposition+ ) async {+ deletionPrompt = nil+ let contract = prompt.contract+ let variants = Self.disclosableVariants(of: contract)+ guard variants.isEmpty else {+ deleteDisclosure = WorkDeleteDisclosure(+ id: workID, disposition: disposition, contract: contract, variants: variants)+ return+ }+ await performDelete(contract, disposition: disposition, disclosing: nil)+ }++ /// The disclosure alert's destructive action. Takes the rendered value as a+ /// parameter rather than re-reading `deleteDisclosure`, which SwiftUI has+ /// already cleared by the time the action runs.+ public func confirmDeletion(_ disclosure: WorkDeleteDisclosure) async {+ deleteDisclosure = nil+ await performDelete(+ disclosure.contract, disposition: disclosure.disposition,+ disclosing: disclosure.disclosedIDs)+ }++ public func cancelDeletionDisclosure() {+ deleteDisclosure = nil+ }++ private func present(_ contract: WorkDeletionContract) {+ deletionPrompt = WorkDeletionPrompt(id: workID, contract: contract)+ }++ /// Sorted so the alert's lines and the disclosed set are derived from one+ /// stable order — an alert listing copies in a different order each time+ /// would be a different disclosure each time.+ private static func disclosableVariants(+ of contract: WorkDeletionContract+ ) -> [AuthoredVariant<EntryAuthoredContent>] {+ contract.tornEntryVariants+ .sorted { $0.key.uuidString < $1.key.uuidString }+ .flatMap(\.value)+ }++ private func performDelete(+ _ contract: WorkDeletionContract,+ disposition: WorkDeletionDisposition,+ disclosing disclosed: Set<VariantID>?+ ) async {+ guard !isSubmitting else { return }+ isSubmitting = true+ state = .submitting+ errorMessage = nil+ defer { isSubmitting = false }++ do {+ let outcome = try await library.commitWorkDeletion(+ contract, disposition: disposition, disclosedVariants: disclosed)+ switch outcome {+ case .committed:+ isDeleted = true+ state = .ready+ await onMutation()+ case .refreshed(let fresh):+ // The prompt named a count; something changed under it, so the+ // whole dialog is presented again rather than the commit+ // silently taking more than was confirmed (Q28).+ present(fresh)+ errorMessage =+ "This work changed while you were deciding. Check what will happen and "+ + "confirm again."+ state = .ready+ case .conflict(let conflict):+ errorMessage = EntryDetailModel.conflictMessage(conflict)+ state = .error(message: errorMessage ?? "")+ await onConflict(conflict)+ // A stale disclosure means a copy arrived while the alert was+ // up. Re-projecting is what puts the current copies in front of+ // the reader instead of the ones they already answered over.+ if case .disclosureStale = conflict { await projectDeletion() }+ case .invalidated(let reason):+ errorMessage = reason+ state = .error(message: reason)+ await load()+ }+ } catch {+ errorMessage = error.localizedDescription+ state = .error(message: error.localizedDescription)+ Self.logger.error(+ "Work deletion failed: \(String(describing: error), privacy: .public)")+ }+ }+ private func commitWorkURL(request: WorkURLRequest) async { guard !isWorkURLSubmitting else { return } isWorkURLSubmitting = true
diff --git a/Asterism/Asterism/Views/ComposedTeachingPresentation.swift b/Asterism/Asterism/Views/ComposedTeachingPresentation.swiftindex 8bfe807..b4353a8 100644--- a/Asterism/Asterism/Views/ComposedTeachingPresentation.swift+++ b/Asterism/Asterism/Views/ComposedTeachingPresentation.swift@@ -6,8 +6,6 @@ import SwiftUI /// and the URL details editor (Req 8.2–8.4). These survive the deletion of the M3 /// `URLTeachingView`, which owned the equivalent URL helpers. public enum ComposedTeachingPresentation {- public static let minimumHitTarget: CGFloat = AsterismLayout.minHitTarget- // Disclosure copy (Req 8.2; final copy set by the visual design pass). public static let disclosureLabel = "Add URL details" public static let disclosureBenefit = "Identify Works and chapters from the page URL for re-share matching."
diff --git a/Asterism/Asterism/Views/ComposedTeachingView.swift b/Asterism/Asterism/Views/ComposedTeachingView.swiftindex 53d22f5..854e7bf 100644--- a/Asterism/Asterism/Views/ComposedTeachingView.swift+++ b/Asterism/Asterism/Views/ComposedTeachingView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// The unified teaching surface (Req 8.1). One screen composes a title rule and@@ -91,7 +92,7 @@ struct ComposedTeachingView: View { VStack(alignment: .leading, spacing: 4) { Text("Example Title").font(.caption).foregroundStyle(.secondary) Text(model.exampleTitle)- .font(AsterismTypography.serifTitle(15))+ .font(AsterismTypography.serifRowTitle) .accessibilityIdentifier("composed-teaching-example-title") } .frame(maxWidth: .infinity, alignment: .leading)@@ -130,20 +131,23 @@ struct ComposedTeachingView: View { // is one static-text element VoiceOver reads. Label(notice, systemImage: "exclamationmark.circle") .font(.caption)- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("composed-title-selection-notice") } + // Q36: the previews wear the same §2/§7 mapping as the chips they+ // preview — the Work reads cyan, the chapter amber. LabeledContent("Work will be named") { Text(model.workNamePreview) .font(.subheadline)- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.cyan) } .accessibilityIdentifier("composed-work-name-preview") if let chapter = model.chapterNamePreview { LabeledContent("Chapter will be named") {- Text(chapter).font(.subheadline).foregroundStyle(AsterismColors.cyanDark)+ // Req 11.4: amber *text* is always the lifted token.+ Text(chapter).font(.subheadline).foregroundStyle(AsterismColors.amberText) } .accessibilityIdentifier("composed-chapter-name-preview") }@@ -190,7 +194,7 @@ struct ComposedTeachingView: View { .frame(minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("composed-articles-stepper") if let message = model.articleValidationMessage {- Text(message).font(.caption).foregroundStyle(.red)+ Text(message).font(.caption).foregroundStyle(AsterismColors.amberText) } if let count = model.articlesPreviewEntryCount { Text("^[\(count) entry](inflect: true) will become independent articles.")@@ -203,8 +207,8 @@ struct ComposedTeachingView: View { .accessibilityIdentifier("composed-articles-cancel") } .padding(12)- .background(.fill.quaternary)- .clipShape(RoundedRectangle(cornerRadius: 8))+ // §4: flat field fill for content inside a sheet.+ .constellationField(cornerRadius: AsterismLayout.cardRadius) .accessibilityElement(children: .contain) .accessibilityIdentifier("composed-articles-confirmation") }@@ -217,7 +221,7 @@ struct ComposedTeachingView: View { .font(.headline) .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget) }- .buttonStyle(.borderedProminent)+ .buttonStyle(.constellationPrimary) .disabled(!model.canConfirmArticles) .accessibilityIdentifier("composed-articles-confirm") }@@ -254,7 +258,7 @@ struct ComposedTeachingView: View { // The disclosure is the remedy, not a passive hint beside a // collapsed section (Req 8.2 as amended); it auto-expands. Text(ComposedTeachingPresentation.chapterRemedyHint)- .font(.caption).foregroundStyle(AsterismColors.amberDark)+ .font(.caption).foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("composed-url-remedy-hint") } else { Text(ComposedTeachingPresentation.disclosureBenefit)@@ -278,21 +282,21 @@ struct ComposedTeachingView: View { } if !outcome.prospectiveWorks.isEmpty { Text("Creates: \(outcome.prospectiveWorks.map(\.displayTitle.value).joined(separator: ", "))")- .font(.caption).foregroundStyle(AsterismColors.cyanDark)+ .font(.caption).foregroundStyle(AsterismColors.cyan) .accessibilityIdentifier("composed-preview-creates") } if !outcome.issues.isEmpty { ForEach(Array(outcome.issues.enumerated()), id: \.offset) { _, issue in let row = ConflictPresentation.row(for: issue) Label(row.text, systemImage: row.symbol)- .font(.caption).foregroundStyle(.orange)+ .font(.caption).foregroundStyle(AsterismColors.amberText) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(12)- .background(.fill.quaternary)- .clipShape(RoundedRectangle(cornerRadius: 8))+ // §4: flat field fill for content inside a sheet.+ .constellationField(cornerRadius: AsterismLayout.cardRadius) .accessibilityIdentifier(isCurrent ? "composed-preview-complete" : "composed-preview-stale") } @@ -300,17 +304,18 @@ struct ComposedTeachingView: View { private func entryRow(_ projection: ComposedEntryProjection, index: Int) -> some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) {+ // Q36: work = cyan, chapter = amber, as the chips read. if let name = projection.workName {- Text(name).font(.caption).foregroundStyle(AsterismColors.amberDark).lineLimit(1)+ Text(name).font(.caption).foregroundStyle(AsterismColors.cyan).lineLimit(1) } if let chapter = projection.projectedChapterTitle {- Text("· \(chapter)").font(.caption).foregroundStyle(AsterismColors.cyanDark).lineLimit(1)+ Text("· \(chapter)").font(.caption).foregroundStyle(AsterismColors.amberText).lineLimit(1) } else if let sequence = projection.projectedChapterSequence {- Text("· #\(sequence)").font(.caption).foregroundStyle(AsterismColors.cyanDark).lineLimit(1)+ Text("· #\(sequence)").font(.caption).foregroundStyle(AsterismColors.amberText).lineLimit(1) } } if projection.actionableAfter {- Text("Still needs attention").font(.caption2).foregroundStyle(.orange)+ Text("Still needs attention").font(.caption2).foregroundStyle(AsterismColors.amberText) } } .accessibilityElement(children: .combine)@@ -323,7 +328,7 @@ struct ComposedTeachingView: View { VStack(alignment: .leading, spacing: 12) { Label("Chapters will stay unsettled", systemImage: "exclamationmark.triangle") .font(.subheadline.weight(.semibold))- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) Text("This title names the Work but does not identify a chapter, and no chapter comes from the URL. Add URL details to source the chapter, or teach anyway and leave chapters unsettled.") .font(.footnote).foregroundStyle(.secondary) Button {@@ -333,12 +338,13 @@ struct ComposedTeachingView: View { .font(.headline) .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget) }- .buttonStyle(.borderedProminent)+ .buttonStyle(.constellationPrimary) .accessibilityIdentifier("composed-acknowledge-confirm") } .padding(12)- .background(AsterismColors.amberDark.opacity(0.1))- .clipShape(RoundedRectangle(cornerRadius: 8))+ // Decision 1: an interstitial the reader has to clear is an+ // actionable-attention surface — amber fill and border.+ .constellationAttentionSurface() // Without `children: .contain` the identifier collapses the panel into a // single element and hides its own confirm button from assistive tech. .accessibilityElement(children: .contain)@@ -352,17 +358,16 @@ struct ComposedTeachingView: View { Task { await model.confirm() } } label: { Text("Teach")- .font(.headline)- .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget) }- .buttonStyle(.borderedProminent)+ // Req 9.1: the teaching sheet's one gradient control.+ .buttonStyle(.constellationPrimary) .disabled(!model.canConfirm) .accessibilityIdentifier("composed-teaching-confirm") } private var committedContent: some View { VStack(spacing: 12) {- Image(systemName: "checkmark.circle.fill").font(.largeTitle).foregroundStyle(.green)+ Image(systemName: "checkmark.circle.fill").font(.largeTitle).foregroundStyle(AsterismColors.cyan) .accessibilityHidden(true) Text("Taught successfully").font(.headline) Button("Done") { dismiss() }@@ -379,7 +384,7 @@ struct ComposedTeachingView: View { .font(.subheadline) if let outcome = model.previewOutcome { previewSection(outcome) } Button("Confirm Again") { Task { await model.reconfirm() } }- .buttonStyle(.borderedProminent)+ .buttonStyle(.constellationPrimary) .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("composed-teaching-reconfirm") }@@ -388,7 +393,7 @@ struct ComposedTeachingView: View { private var invalidatedContent: some View { VStack(spacing: 12) {- Image(systemName: "xmark.circle").font(.largeTitle).foregroundStyle(.red).accessibilityHidden(true)+ Image(systemName: "xmark.circle").font(.largeTitle).foregroundStyle(AsterismColors.amberText).accessibilityHidden(true) Text("Cannot apply teaching").font(.headline) if let reason = model.invalidationReason { Text(reason).font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center)@@ -403,12 +408,12 @@ struct ComposedTeachingView: View { private var errorContent: some View { VStack(spacing: 12) {- Image(systemName: "exclamationmark.triangle.fill").font(.largeTitle).foregroundStyle(.red).accessibilityHidden(true)+ Image(systemName: "exclamationmark.triangle.fill").font(.largeTitle).foregroundStyle(AsterismColors.amberText).accessibilityHidden(true) if let message = model.errorMessage { Text(message).font(.subheadline).multilineTextAlignment(.center) } Button("Try Again") { Task { await model.retry() } }- .buttonStyle(.borderedProminent)+ .buttonStyle(.constellationPrimary) .frame(minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("composed-teaching-retry") }
diff --git a/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift b/Asterism/Asterism/Views/ComposedURLDetailsEditor.swiftindex ca64617..f4fdf5f 100644--- a/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift+++ b/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// One selected raw URL component: a whole path component or a query item.@@ -192,7 +193,7 @@ struct ComposedURLDetailsEditor: View { publish(nil) } .font(.footnote)- .frame(minHeight: ComposedTeachingPresentation.minimumHitTarget)+ .frame(minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("composed-url-clear") } }@@ -218,15 +219,14 @@ struct ComposedURLDetailsEditor: View { HStack(spacing: 4) { Text(chipSymbol(for: role)) .font(.caption.monospaced().bold())- .foregroundStyle(chipColor(for: role))- Text(text).font(.subheadline).lineLimit(1)+ Text(text).lineLimit(1) }- .padding(.horizontal, 12)- .padding(.vertical, 8)- .frame(minHeight: ComposedTeachingPresentation.minimumHitTarget)- .background(chipColor(for: role).opacity(0.12))- .clipShape(Capsule())- .overlay(Capsule().stroke(chipColor(for: role).opacity(0.4), lineWidth: 1))+ .frame(minHeight: AsterismLayout.minHitTarget)+ // §7: URL chips are the chip recipe in SF Mono at radius 12.+ .constellationChip(+ role: chipRole(for: role),+ isSelected: role == .work || role == .sequence,+ isMonospaced: true) } .buttonStyle(.plain) .disabled(isBlank)@@ -252,11 +252,13 @@ struct ComposedURLDetailsEditor: View { } } - private func chipColor(for role: ChipRole) -> Color {+ /// Q36: the style guide's §7 mapping — the Work component is cyan, the+ /// chapter-sequence component amber. The shipped inverse is superseded.+ private func chipRole(for role: ChipRole) -> ConstellationChipRole { switch role {- case .work: AsterismColors.amberDark- case .sequence: AsterismColors.cyanDark- case .empty, .none: .secondary+ case .work: .work+ case .sequence: .chapter+ case .empty, .none: .ignored } } @@ -299,7 +301,7 @@ struct ComposedURLDetailsEditor: View { Text("Split “\(text)” into a Work part and a chapter part.") .font(.footnote).foregroundStyle(.secondary) }- .frame(maxWidth: .infinity, minHeight: ComposedTeachingPresentation.minimumHitTarget, alignment: .leading)+ .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain)@@ -315,7 +317,8 @@ struct ComposedURLDetailsEditor: View { .font(.caption).foregroundStyle(.secondary) splitTokenRow(text: text, split: split) if let splitErrorMessage {- Text(splitErrorMessage).font(.caption).foregroundStyle(.red)+ // Q37: amber, not system red — the palette has no error colour.+ Text(splitErrorMessage).font(.caption).foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("composed-url-split-error") } Button("Use the whole component instead") {@@ -324,12 +327,13 @@ struct ComposedURLDetailsEditor: View { dispatchRuleDefinition() } .font(.footnote)- .frame(minHeight: ComposedTeachingPresentation.minimumHitTarget)+ .frame(minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("composed-url-unsplit-button") } .padding(12)- .background(Color(.secondarySystemBackground))- .clipShape(RoundedRectangle(cornerRadius: 12))+ // §4: content inside a sheet sits on the flat field fill — no border,+ // no blur, no shadow.+ .constellationField() .accessibilityElement(children: .contain) .accessibilityIdentifier("composed-url-split-editor") }@@ -344,19 +348,19 @@ struct ComposedURLDetailsEditor: View { let tokenText = String(characters[tokenRange]) let isWork = tokenRange.overlaps(split.work) let isSequence = tokenRange.overlaps(split.sequence)- let color: Color = isWork ? AsterismColors.amberDark : isSequence ? AsterismColors.cyanDark : .secondary+ // Q36's mapping again: Work cyan, chapter sequence amber.+ let tokenRole: ConstellationChipRole =+ isWork ? .work : isSequence ? .chapter : .ignored Button { toggleSplitToken(at: index, tokens: tokens) } label: { Text(tokenText)- .font(.footnote.monospaced()) .lineLimit(1)- .padding(.horizontal, 10)- .padding(.vertical, 6)- .frame(minHeight: ComposedTeachingPresentation.minimumHitTarget)- .background(color.opacity(0.12))- .clipShape(Capsule())- .overlay(Capsule().stroke(color.opacity(0.4), lineWidth: 1))+ .frame(minHeight: AsterismLayout.minHitTarget)+ .constellationChip(+ role: tokenRole,+ isSelected: isWork || isSequence,+ isMonospaced: true) } .buttonStyle(.plain) .accessibilityIdentifier("composed-url-split-token-\(index)")
diff --git a/Asterism/Asterism/Views/DuplicateResolutionView.swift b/Asterism/Asterism/Views/DuplicateResolutionView.swiftindex 8b7dea7..8be2332 100644--- a/Asterism/Asterism/Views/DuplicateResolutionView.swift+++ b/Asterism/Asterism/Views/DuplicateResolutionView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// The resolution sheet (Requirement 4, and Req 5.4's Work half).@@ -74,7 +75,7 @@ struct DuplicateResolutionView: View { "Another copy arrived while this was open, so these are the copies as they stand now." ) .font(.caption)- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("duplicate-resolution-refreshed-notice") } }@@ -87,7 +88,14 @@ struct DuplicateResolutionView: View { variantRow(id: variant.id) { workVariantContent(variant) } } } header: {+ // Decision 1 and Q46: duplicate review is an+ // actionable-attention surface, and this header is the sheet's+ // question rather than a label for a list — so it keeps the+ // section-header font in amber, in sentence case and without+ // the ✦ prefix. The one documented exception to 8.4. Text("Which copy do you want to keep?")+ .font(AsterismTypography.sectionHeader)+ .foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("duplicate-resolution-header") } @@ -109,20 +117,20 @@ struct DuplicateResolutionView: View { if let errorMessage = model.errorMessage { Section {+ // Q37: amber, not system red. Text(errorMessage)- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("duplicate-resolution-error-message") } } Section {+ // Req 9.1: the sheet's one primary action. Button("Keep this copy") { Task { await model.confirm() } }+ .buttonStyle(.constellationPrimary) .disabled(!model.canConfirm)- .frame(- minWidth: AsterismLayout.minHitTarget,- minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("duplicate-resolution-confirm") } footer: { Text("The other copies are removed. Nothing happens until you tap this.")@@ -142,7 +150,7 @@ struct DuplicateResolutionView: View { systemName: model.selectedVariantID == id ? "largecircle.fill.circle" : "circle" )- .foregroundStyle(AsterismColors.cyanDark)+ .foregroundStyle(AsterismColors.cyan) .accessibilityHidden(true) content() Spacer(minLength: 0)@@ -171,7 +179,7 @@ struct DuplicateResolutionView: View { if model.differingFields.contains(.chapterTitle), let chapter = variant.chapterTitle { Text(chapter) .font(.caption)- .foregroundStyle(AsterismColors.cyanDark)+ .foregroundStyle(AsterismColors.cyan) } if model.differingFields.contains(.workAssignment) { Text(variant.workTitle ?? "Not assigned")@@ -196,7 +204,7 @@ struct DuplicateResolutionView: View { private func workVariantContent(_ variant: WorkVariantChoice) -> some View { VStack(alignment: .leading, spacing: 4) { Text(variant.displayTitle)- .font(AsterismTypography.serifTitle(15))+ .font(AsterismTypography.serifRowTitle) .accessibilityIdentifier("duplicate-variant-title") if !variant.genericNotes.isEmpty { Text(variant.genericNotes)@@ -208,7 +216,7 @@ struct DuplicateResolutionView: View { if model.differingFields.contains(.type) { Text(variant.type.rawValue) .font(.caption2.weight(.semibold))- .foregroundStyle(AsterismColors.violetDark)+ .foregroundStyle(AsterismColors.violet) } if model.differingFields.contains(.genreTags), !variant.genreTags.isEmpty { Text(variant.genreTags.joined(separator: ", "))
diff --git a/Asterism/Asterism/Views/EntryDetailView.swift b/Asterism/Asterism/Views/EntryDetailView.swiftindex 3a68591..df98b27 100644--- a/Asterism/Asterism/Views/EntryDetailView.swift+++ b/Asterism/Asterism/Views/EntryDetailView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// Entry detail: shows entry metadata and provides note/rating editing, assignment,@@ -8,6 +9,10 @@ struct EntryDetailView: View { @Environment(\.dismiss) private var dismiss @State private var showingComposedTeaching = false @State private var showingReparse = false+ /// Req 1.5's staging model. `@State`-owned like `SettingsView`'s: it holds+ /// the staged file, and a fresh instance per re-render would lose track of+ /// what there is to clean up.+ @State private var exportModel: MarkdownExportModel? let onMoveTo: () -> Void /// Req 9.2's route out of a torn record, or nil where the caller has no /// sheet to open (previews, and the surfaces that host this without one).@@ -16,10 +21,12 @@ struct EntryDetailView: View { init( model: EntryDetailModel, onMoveTo: @escaping () -> Void,- onResolveDuplicate: (() -> Void)? = nil+ onResolveDuplicate: (() -> Void)? = nil,+ exportModel: MarkdownExportModel? = nil ) { self.onResolveDuplicate = onResolveDuplicate _model = State(initialValue: model)+ _exportModel = State(initialValue: exportModel) self.onMoveTo = onMoveTo } @@ -46,26 +53,38 @@ struct EntryDetailView: View { } } .navigationTitle("Entry")+ // Requirement 8.1's fixed layer. On the screen's own root rather than+ // on the enclosing `NavigationStack`: a background applied outside the+ // stack renders behind the stack's own opaque backing and never+ // reaches the screen.+ .background { ConstellationBackground() } .task { await model.load() } } @ViewBuilder private func entryContent(_ entry: EntrySnapshot) -> some View { Form {- Section("Details") {+ Section { LabeledContent("Title") {+ // Req 8.3: the chapter heading is the serif face. Text(model.teachingDetail?.displayTitle ?? entry.captureTitle)- .font(AsterismTypography.serifTitle(17))+ .font(AsterismTypography.serifHeading)+ .foregroundStyle(AsterismColors.primaryText) } .accessibilityIdentifier("entry-detail-title") LabeledContent("Site") {- Text(entry.hostname)+ HStack(spacing: 6) {+ SiteGlyph(hostname: entry.hostname, size: 18)+ Text(entry.hostname)+ .foregroundStyle(AsterismColors.secondaryText)+ } } .accessibilityIdentifier("entry-detail-hostname") LabeledContent("Shared") { Text(entry.lastSharedAt, style: .date)+ .foregroundStyle(AsterismColors.secondaryText) } .accessibilityIdentifier("entry-detail-shared") @@ -74,15 +93,19 @@ struct EntryDetailView: View { .frame(minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("entry-detail-link") }+ } header: {+ ConstellationSectionHeader("Details") } if model.supportsTitleRecovery, let detail = model.teachingDetail, !detail.availableActions.isEmpty {- Section("Title Recovery") {+ Section { ForEach(detail.availableActions, id: \.self) { action in actionButton(for: action) }+ } header: {+ ConstellationSectionHeader("Title Recovery", accent: .violet) } } @@ -92,39 +115,50 @@ struct EntryDetailView: View { if model.isReadOnly { duplicateReviewSection } - Section("Note") {+ Section { TextEditor(text: $model.draftNote) .frame(minHeight: 80)+ .scrollContentBackground(.hidden)+ .padding(8)+ .constellationField() .disabled(model.isReadOnly) .accessibilityIdentifier("entry-detail-note-editor") .accessibilityLabel("Entry note")+ } header: {+ ConstellationSectionHeader("Note", accent: .violet) } - Section("Rating") {+ Section { HStack(spacing: 16) { ratingButton(.up) ratingButton(.down) Spacer() } .disabled(model.isReadOnly)+ } header: {+ ConstellationSectionHeader("Rating", accent: .violet) } if let errorMessage = model.errorMessage { Section {+ // Q37: amber, not system red — the palette has no error+ // colour and the failure is recoverable. Text(errorMessage)- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("entry-detail-error") } } Section {+ // Req 9.1: this screen's single gradient control. Move to,+ // Export, and Delete stay ordinary rows. Button("Update") { Task { await model.update() } }+ .buttonStyle(.constellationPrimary) // Req 2.8: a torn record's authored fields are read-only until // its resolution. Deleting stays available — it discloses first. .disabled(model.state == .submitting || model.isReadOnly)- .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("entry-detail-update-button") Button("Move to") {@@ -134,6 +168,11 @@ struct EntryDetailView: View { .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("entry-detail-move-to-button") + // Req 1.1. Available on a torn record too, unlike the editing+ // actions: export never refuses over duplicate state (1.8), it+ // renders the same carrier the screen above is showing.+ exportRow+ Button("Delete Entry", role: .destructive) { Task { await model.delete()@@ -145,6 +184,8 @@ struct EntryDetailView: View { .accessibilityIdentifier("entry-detail-delete-button") } }+ // Req 8.1: the tab stack's sky shows through this pushed screen.+ .scrollContentBackground(.hidden) // Req 2.8's disclosure. The alert lists the copies that differ, and its // destructive button is the *only* way the delete gets a disclosure to // hand the commit — so a group cannot go while the reader has been told@@ -188,6 +229,32 @@ struct EntryDetailView: View { ReparseView(model: reparseModel) } }+ .markdownExportShare(+ model: exportModel, sheetIdentifier: "entry-detail-export-share-sheet")+ }++ /// Req 1.1's action, and the failure line beside it.+ ///+ /// Wording comes from the model, which owns the privacy rule — this view+ /// never composes a message about an export.+ @ViewBuilder+ private var exportRow: some View {+ if let exportModel {+ Button(exportModel.state == .exporting ? "Preparing export…" : "Export") {+ Task { await exportModel.startExport() }+ }+ .disabled(exportModel.state == .exporting)+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("entry-detail-export-button")+ .accessibilityLabel("Export this note as markdown")++ if exportModel.state == .failed, let message = exportModel.errorMessage {+ Text(message)+ .font(.caption)+ .foregroundStyle(.secondary)+ .accessibilityIdentifier("entry-detail-export-error")+ }+ } } @ViewBuilder@@ -224,20 +291,24 @@ struct EntryDetailView: View { /// and offer the route that turns them back on. private var duplicateReviewSection: some View { Section {- VStack(alignment: .leading, spacing: 6) {+ VStack(alignment: .leading, spacing: 8) { Text("This entry arrived \(model.rowCount) times and the copies differ.") .font(.callout)+ .foregroundStyle(AsterismColors.primaryText) Text("Editing is off until you choose which one to keep — nothing has been lost.") .font(.caption)- .foregroundStyle(.secondary)+ .foregroundStyle(AsterismColors.secondaryText) if let onResolveDuplicate { Button("Resolve copies") { onResolveDuplicate() }- .frame(- minWidth: AsterismLayout.minHitTarget,- minHeight: AsterismLayout.minHitTarget)+ .buttonStyle(.constellationSecondary) .accessibilityIdentifier("entry-detail-resolve-duplicate-button") } }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ // Decision 1: duplicate review is actionable attention → amber.+ .constellationCard(borderColor: AsterismColors.attentionBorder)+ .constellationListRow() .accessibilityIdentifier("entry-detail-duplicate-notice") } }@@ -252,11 +323,10 @@ struct EntryDetailView: View { return ([opening] + disclosure.lines).joined(separator: "\n\n") } + /// §7's rating toggle: a 48×42 capsule, accent fill and glow when active. @ViewBuilder private func ratingButton(_ rating: Rating) -> some View { let isSelected = model.draftRating == rating- let label = rating == .up ? "▲" : "▼"- let color = rating == .up ? AsterismColors.cyanDark : AsterismColors.violetDark Button { if model.draftRating == rating {@@ -265,12 +335,12 @@ struct EntryDetailView: View { model.draftRating = rating } } label: {- Text(label)+ Text(rating == .up ? "▲" : "▼") .font(.title3)- .foregroundStyle(isSelected ? color : .secondary)- .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget) }- .buttonStyle(.plain)+ .buttonStyle(+ .constellationRatingToggle(rating == .up ? .up : .down, isActive: isSelected)+ ) .accessibilityIdentifier("entry-detail-rating-\(rating.rawValue)") .accessibilityLabel(rating == .up ? "Rate up" : "Rate down") .accessibilityAddTraits(isSelected ? .isSelected : [])@@ -278,6 +348,8 @@ struct EntryDetailView: View { // MARK: - Provenance disclosure + /// §7's provenance disclosure: quieter-than-card fill, SF Mono 11 pt, dim,+ /// labels slightly brighter, collapsed by default. @ViewBuilder private func provenanceSection(_ entry: EntrySnapshot) -> some View { Section {@@ -285,19 +357,19 @@ struct EntryDetailView: View { VStack(alignment: .leading, spacing: 8) { LabeledContent("Capture Title") { Text(entry.captureTitle)- .font(.caption)+ .font(AsterismTypography.mono) } .accessibilityIdentifier("provenance-capture-title") LabeledContent("Source") { Text(entry.captureTitleSource.rawValue)- .font(.caption)+ .font(AsterismTypography.mono) } .accessibilityIdentifier("provenance-title-source") LabeledContent("URL") { Text(entry.rawURLString)- .font(.caption)+ .font(AsterismTypography.mono) .lineLimit(2) } .accessibilityIdentifier("provenance-raw-url")@@ -305,7 +377,7 @@ struct EntryDetailView: View { if let canonical = entry.canonicalURLString { LabeledContent("Canonical URL") { Text(canonical)- .font(.caption)+ .font(AsterismTypography.mono) .lineLimit(2) } .accessibilityIdentifier("provenance-canonical-url")@@ -314,20 +386,20 @@ struct EntryDetailView: View { // Chapter provenance with settlement reason LabeledContent("Chapter") { Text(entry.chapterTitle ?? "—")- .font(.caption)+ .font(AsterismTypography.mono) } .accessibilityIdentifier("provenance-chapter") if let detail = model.teachingDetail { LabeledContent("Chapter Settlement") { Text(settlementLabel(detail.chapterSettlement))- .font(.caption)+ .font(AsterismTypography.mono) } .accessibilityIdentifier("provenance-chapter-settlement") LabeledContent("Assignment Settlement") { Text(settlementLabel(detail.assignmentSettlement))- .font(.caption)+ .font(AsterismTypography.mono) } .accessibilityIdentifier("provenance-assignment-settlement") @@ -344,20 +416,25 @@ struct EntryDetailView: View { // Fallback when detail not yet loaded LabeledContent("Chapter Provenance") { Text(provenanceKindLabel(entry.chapterTitleProvenance))- .font(.caption)+ .font(AsterismTypography.mono) } .accessibilityIdentifier("provenance-chapter-kind") LabeledContent("Assignment Provenance") { Text(provenanceKindLabel(entry.workAssignmentProvenance))- .font(.caption)+ .font(AsterismTypography.mono) } .accessibilityIdentifier("provenance-assignment-kind") } }+ .foregroundStyle(AsterismColors.secondaryText)+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(10)+ .constellationProvenanceSurface() } label: { Text("Provenance") .font(.subheadline)+ .foregroundStyle(AsterismColors.primaryText) } } .accessibilityIdentifier("entry-detail-provenance")@@ -368,20 +445,23 @@ struct EntryDetailView: View { VStack(alignment: .leading, spacing: 4) { Text(label) .font(.caption2)- .foregroundStyle(.secondary)+ .foregroundStyle(AsterismColors.secondaryText) Text(summary.plainLanguageRule)- .font(.caption)+ .font(AsterismTypography.mono) HStack { Text("ID: \(summary.id.uuidString.prefix(8))…")- .font(.caption2.monospaced())- .foregroundStyle(.secondary)+ .font(AsterismTypography.mono)+ .foregroundStyle(AsterismColors.secondaryText) Text("v\(summary.version)")- .font(.caption2.monospaced())- .foregroundStyle(.secondary)+ .font(AsterismTypography.mono)+ .foregroundStyle(AsterismColors.secondaryText)+ // Q37: the positive/active marker is cyan, not system green —+ // §2 gives cyan the "yes/active" role and §11 forbids a fourth+ // accent hue. if summary.isActive { Text("active") .font(.caption2)- .foregroundStyle(.green)+ .foregroundStyle(AsterismColors.cyan) } } }
diff --git a/Asterism/Asterism/Views/MaintenanceViews.swift b/Asterism/Asterism/Views/MaintenanceViews.swiftindex d48d2fe..7000f89 100644--- a/Asterism/Asterism/Views/MaintenanceViews.swift+++ b/Asterism/Asterism/Views/MaintenanceViews.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// The `Review URL identity` maintenance surface (Req 7.2), reached from Work@@ -48,7 +49,7 @@ struct URLIdentityReviewView: View { private var reviewContent: some View { Form { if let projection = model.projection {- Section("Summary") {+ Section(header: ConstellationSectionHeader("Summary", accent: .cyan)) { LabeledContent("Entries evaluated") { Text("\(projection.entries.count)") } .accessibilityIdentifier("review-entry-count") LabeledContent("Works evaluated") { Text("\(projection.works.count)") }@@ -58,11 +59,11 @@ struct URLIdentityReviewView: View { if projection.issues.isEmpty { Section { Label("No conflicts", systemImage: "checkmark.circle")- .foregroundStyle(.green)+ .foregroundStyle(AsterismColors.cyan) .accessibilityIdentifier("review-no-issues") } } else {- Section("Conflicts") {+ Section(header: ConstellationSectionHeader("Conflicts", accent: .violet)) { ForEach(Array(projection.issues.enumerated()), id: \.offset) { index, issue in let row = ConflictPresentation.row(for: issue) Label(row.text, systemImage: row.symbol)@@ -73,7 +74,7 @@ struct URLIdentityReviewView: View { } } - Section("Work evidence") {+ Section(header: ConstellationSectionHeader("Work evidence", accent: .violet)) { ForEach(Array(projection.works.enumerated()), id: \.offset) { index, work in LabeledContent(evidenceLabel(work.evidence)) { Text(work.workID.uuidString.prefix(8) + "…")@@ -116,9 +117,14 @@ struct URLIdentityReviewView: View { /// nested stack would swallow the back affordance on either. struct LibraryDiagnosticsView: View { @State private var model: LibraryDiagnosticsModel+ /// Requirement 8.1 puts the sky behind tab-stack content only, and this+ /// screen is pushed from two places: Recent's stack (sky) and the Settings+ /// sheet (no sky — a sheet is its own glass layer, §4). The host says which.+ private let showsSky: Bool - init(model: LibraryDiagnosticsModel) {+ init(model: LibraryDiagnosticsModel, showsSky: Bool = false) { _model = State(initialValue: model)+ self.showsSky = showsSky } var body: some View {@@ -142,6 +148,7 @@ struct LibraryDiagnosticsView: View { } .navigationTitle("Library Check") .navigationBarTitleDisplayMode(.inline)+ .background { if showsSky { ConstellationBackground() } } .task { await model.load() } } @@ -153,7 +160,7 @@ struct LibraryDiagnosticsView: View { .font(.headline) // Q21: damage is not a routine count, and the colour is a // reinforcement of the wording, never the only signal.- .foregroundStyle(model.suggestsDamage ? AnyShapeStyle(Color.red) : AnyShapeStyle(.primary))+ .foregroundStyle(model.suggestsDamage ? AnyShapeStyle(AsterismColors.amberText) : AnyShapeStyle(AsterismColors.primaryText)) Text(model.detail) .font(.footnote) .foregroundStyle(.secondary)@@ -238,17 +245,17 @@ struct RecalculateView: View { case .confirming: ProgressView("Recalculating…").accessibilityIdentifier("recalculate-confirming") case .committed:- terminal(symbol: "checkmark.circle.fill", color: .green, title: "Recalculated",+ terminal(symbol: "checkmark.circle.fill", color: AsterismColors.cyan, title: "Recalculated", id: "recalculate-committed") case .noChanges:- terminal(symbol: "equal.circle", color: .secondary, title: "Nothing to recalculate",+ terminal(symbol: "equal.circle", color: AsterismColors.secondaryText, title: "Nothing to recalculate", id: "recalculate-no-changes") case .invalidated:- terminal(symbol: "xmark.circle", color: .red,+ terminal(symbol: "xmark.circle", color: AsterismColors.amberText, title: model.invalidationReason ?? "Recalculation is no longer valid", id: "recalculate-invalidated") case .error:- terminal(symbol: "exclamationmark.triangle.fill", color: .red,+ terminal(symbol: "exclamationmark.triangle.fill", color: AsterismColors.amberText, title: model.errorMessage ?? "An error occurred", id: "recalculate-error") } }@@ -275,16 +282,18 @@ struct RecalculateView: View { } } if let outcome = model.previewOutcome {- Section("Preview (\(outcome.entries.count) entries)") {+ Section(header: ConstellationSectionHeader("Preview (\(outcome.entries.count) entries)", accent: .violet)) { ForEach(Array(outcome.entries.prefix(8).enumerated()), id: \.offset) { index, projection in VStack(alignment: .leading, spacing: 2) {+ // Q36: work = cyan, chapter = amber, as the teach+ // chips read. if let name = projection.workName {- Text(name).font(.caption).foregroundStyle(AsterismColors.amberDark).lineLimit(1)+ Text(name).font(.caption).foregroundStyle(AsterismColors.cyan).lineLimit(1) } if let chapter = projection.projectedChapterTitle {- Text(chapter).font(.caption2).foregroundStyle(AsterismColors.cyanDark)+ Text(chapter).font(.caption2).foregroundStyle(AsterismColors.amberText) } else if let sequence = projection.projectedChapterSequence {- Text("#\(sequence)").font(.caption2).foregroundStyle(AsterismColors.cyanDark)+ Text("#\(sequence)").font(.caption2).foregroundStyle(AsterismColors.amberText) } } .accessibilityElement(children: .combine)@@ -292,7 +301,7 @@ struct RecalculateView: View { } } if !outcome.issues.isEmpty {- Section("Conflicts") {+ Section(header: ConstellationSectionHeader("Conflicts", accent: .violet)) { ForEach(Array(outcome.issues.enumerated()), id: \.offset) { _, issue in let row = ConflictPresentation.row(for: issue) Label(row.text, systemImage: row.symbol).font(.caption)@@ -304,7 +313,7 @@ struct RecalculateView: View { Button(model.state == .refreshed ? "Confirm Again" : "Recalculate") { Task { await model.confirm() } }- .buttonStyle(.borderedProminent)+ .buttonStyle(.constellationPrimary) .disabled(!model.canConfirm && model.state != .refreshed) .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("recalculate-confirm")
diff --git a/Asterism/Asterism/Views/MoveToView.swift b/Asterism/Asterism/Views/MoveToView.swiftindex 06fd2e7..57edfe5 100644--- a/Asterism/Asterism/Views/MoveToView.swift+++ b/Asterism/Asterism/Views/MoveToView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// Move-to sheet: same-host Works, New Work, and Leave unattached.@@ -42,13 +43,15 @@ struct MoveToView: View { private var destinationsList: some View { List { if !model.destinations.isEmpty {- Section("Same-host Works") {+ Section { ForEach(model.destinations, id: \.id) { work in Button { Task { await model.moveToExisting(work.id) } } label: { Text(work.displayTitle)- .font(AsterismTypography.serifTitle(15))+ .font(AsterismTypography.serifRowTitle)+ .lineLimit(1)+ .truncationMode(.tail) } .disabled(model.state == .submitting) .frame(@@ -58,6 +61,8 @@ struct MoveToView: View { .accessibilityIdentifier("move-to-work-destination") .accessibilityLabel("Move to \(work.displayTitle)") }+ } header: {+ ConstellationSectionHeader("Same-host Works") } } @@ -93,7 +98,7 @@ struct MoveToView: View { if let errorMessage = model.errorMessage { Section { Text(errorMessage)- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("move-to-error") } }
diff --git a/Asterism/Asterism/Views/NewWorkView.swift b/Asterism/Asterism/Views/NewWorkView.swiftindex 8e484b3..4cd79ad 100644--- a/Asterism/Asterism/Views/NewWorkView.swift+++ b/Asterism/Asterism/Views/NewWorkView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// New Work form: title and hostname fields with explicit Create action.@@ -13,7 +14,7 @@ struct NewWorkView: View { var body: some View { NavigationStack { Form {- Section("New Work") {+ Section { TextField("Title", text: $model.draftTitle) .accessibilityIdentifier("new-work-title-field") @@ -21,25 +22,28 @@ struct NewWorkView: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() .accessibilityIdentifier("new-work-hostname-field")+ } header: {+ ConstellationSectionHeader("New Work") } if let errorMessage = model.errorMessage { Section { Text(errorMessage)- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("new-work-error") } } Section {+ // Req 9.1: the sheet's one gradient control. Button("Create") { Task { await model.create() if case .created = model.state { dismiss() } } }+ .buttonStyle(.constellationPrimary) .disabled(model.state == .submitting)- .frame(minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("new-work-create-button") } }
diff --git a/Asterism/Asterism/Views/PostTeachingWorkURLView.swift b/Asterism/Asterism/Views/PostTeachingWorkURLView.swiftindex a74190b..5de05ff 100644--- a/Asterism/Asterism/Views/PostTeachingWorkURLView.swift+++ b/Asterism/Asterism/Views/PostTeachingWorkURLView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// Post-teaching Work URL confirmation queue: each affected Work's candidate@@ -44,9 +45,9 @@ struct PostTeachingWorkURLView: View { .accessibilityIdentifier("post-teaching-progress") } - Section("Work") {+ Section(header: ConstellationSectionHeader("Work", accent: .cyan)) { Text(candidate.workTitle)- .font(AsterismTypography.serifTitle(17))+ .font(AsterismTypography.serifHeading) .accessibilityIdentifier("post-teaching-work-title") switch candidate.candidate {@@ -68,7 +69,7 @@ struct PostTeachingWorkURLView: View { if let errorMessage = model.errorMessage { Section { Text(errorMessage)- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("post-teaching-error") } }@@ -104,7 +105,7 @@ struct PostTeachingWorkURLView: View { VStack(spacing: 16) { Image(systemName: "checkmark.circle.fill") .font(.largeTitle)- .foregroundStyle(.green)+ .foregroundStyle(AsterismColors.cyan) .accessibilityHidden(true) Text("Work URLs reviewed") .font(.headline)@@ -112,7 +113,7 @@ struct PostTeachingWorkURLView: View { .font(.subheadline) .foregroundStyle(.secondary) Button("Done") { onDone() }- .buttonStyle(.borderedProminent)+ .buttonStyle(.constellationPrimary) .frame(minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("post-teaching-complete-done") }
diff --git a/Asterism/Asterism/Views/RecentView.swift b/Asterism/Asterism/Views/RecentView.swiftindex 2afcdb1..ffea745 100644--- a/Asterism/Asterism/Views/RecentView.swift+++ b/Asterism/Asterism/Views/RecentView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// Recent tab: shows all entries grouped by day, ordered by lastSharedAt descending.@@ -37,6 +38,9 @@ struct RecentView: View { @State private var showingActionableOnly = false @State private var showingDuplicatesOnly = false+ /// Req 3.1's query, beside the banner filters because it composes with them+ /// rather than replacing them (3.2, Q6).+ @State private var searchQuery = "" init( presentation: RecentPresentation,@@ -82,7 +86,37 @@ struct RecentView: View { } } + 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+ }+ 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 // 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@@ -96,34 +130,49 @@ struct RecentView: View { // outside Recent, and the section below is the only route to it. if presentation.groups.isEmpty && !showingDuplicatesOnly { emptyBranch+ } else if showsSearchEmptyState(for: searchedGroups) {+ searchEmptyBranch } else { List {- ForEach(displayGroups, id: \.day) { group in+ ForEach(Array(searchedGroups.enumerated()), id: \.element.day) { index, group in Section { ForEach(group.rows) { row in RecentEntryRow( row: row, onSelect: onSelect, onTeach: onTeach,- onResolveDuplicate: onResolveDuplicate.map { resolve in- { key in resolve(key) }- },+ onResolveDuplicate: onResolveDuplicate, resolutionKey: duplicateSetKey(for: row) )+ .constellationListRow() } } header: {- Text(group.day, style: .date)- .font(AsterismTypography.sectionHeader)- .textCase(.uppercase)- .accessibilityIdentifier("recent-section-header")+ // Req 8.4: ✦ cyan on the first section, violet on+ // the ones after it (§3).+ ConstellationSectionHeader(+ group.day.formatted(date: .abbreviated, time: .omitted),+ accent: index == 0 ? .cyan : .violet+ )+ .accessibilityIdentifier("recent-section-header") } } if showingDuplicatesOnly { elsewhereSection } } .listStyle(.plain)+ // Req 8.1: the sky is the tab stack's background and the+ // content scrolls over it.+ .scrollContentBackground(.hidden) .accessibilityIdentifier("recent-list") } }+ // Requirement 8.1's fixed layer. On the screen's own root rather than+ // on the enclosing `NavigationStack`: a background applied outside the+ // stack renders *behind* the stack's own opaque backing, and the sky+ // never reaches the screen (verified in the simulator — pure black).+ .background { ConstellationBackground() }+ // On the VStack, not the List: the List is swapped out for the empty+ // branches, and a search field attached to it would go with them.+ .searchable(text: $searchQuery, prompt: "Search notes") .onChange(of: presentation.actionableCount) { _, newCount in if newCount == 0 { showingActionableOnly = false@@ -161,6 +210,14 @@ struct RecentView: View { } } + /// Req 3.4's message. Distinct from `emptyBranch`, which describes a library+ /// with nothing in it — this one describes a query with nothing behind it,+ /// and the difference matters to a reader who has just typed.+ private var searchEmptyBranch: some View {+ ContentUnavailableView.search(text: searchQuery)+ .accessibilityIdentifier("recent-search-empty")+ }+ /// The actionable banner ranks first when both apply: its action is the /// routine one, and the diagnosis banner reports something the reader is not /// expected to be doing every day (Q15).@@ -215,23 +272,33 @@ struct RecentView: View { HStack(spacing: 6) { Image(systemName: icon) .font(.caption)- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text(text) .font(.caption)- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) Spacer() if let trailing { Text(trailing) .font(.caption.bold())- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) }+ // §7: the banner ends in a chevron — it always goes somewhere,+ // even when its call-to-action word is absent.+ Image(systemName: "chevron.right")+ .font(.caption2.weight(.semibold))+ .foregroundStyle(AsterismColors.amberText)+ .accessibilityHidden(true) } .padding(.horizontal)- .frame(minHeight: AsterismLayout.minHitTarget)- .background(AsterismColors.amberDark.opacity(0.1))+ // §7's amber-tinted glass capsule, replacing the flat tint these+ // banners carried. Amber covers both teaching and duplicate review+ // here (Decision 1).+ .constellationBanner() } .buttonStyle(.plain)+ .padding(.horizontal, 16)+ .padding(.vertical, 4) .accessibilityElement(children: .combine) .accessibilityIdentifier(identifier) .accessibilityLabel(accessibilityLabel)@@ -239,8 +306,11 @@ struct RecentView: View { private var actionableBanner: some View { bannerButton(+ // §7/§8: the inbox banner's glyph is the app's ✦ mark. The filter+ // symbol replaces it only while the banner is the active filter,+ // where it reports state rather than identity. icon: showingActionableOnly- ? "line.3.horizontal.decrease.circle.fill" : "star.fill",+ ? "line.3.horizontal.decrease.circle.fill" : "sparkle", text: showingActionableOnly ? "Showing \(presentation.actionableCount) actionable" : "\(presentation.actionableCount) entries need teaching",@@ -273,9 +343,8 @@ struct RecentView: View { } private var duplicateBannerText: String {- duplicateCount == 1- ? "1 duplicate needs your decision"- : "\(duplicateCount) duplicates need your decision"+ Pluralisation.count(duplicateCount, "duplicate needs", "duplicates need")+ + " your decision" } /// What the banner counted that Recent holds no row for (Req 9.1).@@ -303,22 +372,26 @@ struct RecentView: View { HStack(spacing: 8) { Image(systemName: "square.on.square.dashed") .font(.caption)- .foregroundStyle(AsterismColors.amberDark)+ .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: {- Text("Elsewhere")- .font(AsterismTypography.sectionHeader)- .textCase(.uppercase)+ ConstellationSectionHeader("Elsewhere", accent: .violet) .accessibilityIdentifier("duplicate-elsewhere-header") } }@@ -368,9 +441,8 @@ struct RecentView: View { } private var diagnosisBannerText: String {- presentation.diagnosisCount == 1- ? "1 record could not be resolved"- : "\(presentation.diagnosisCount) records could not be resolved"+ Pluralisation.count(presentation.diagnosisCount, "record", "records")+ + " could not be resolved" } /// Req 4.3. `refreshAll` swallows its errors; the diagnosis count must not@@ -380,16 +452,21 @@ struct RecentView: View { HStack(spacing: 6) { Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90") .font(.caption)- .foregroundStyle(.secondary)+ .foregroundStyle(AsterismColors.secondaryText) .accessibilityHidden(true) Text("Asterism could not re-check your library. Any count shown may be out of date.") .font(.caption)- .foregroundStyle(.secondary)+ .foregroundStyle(AsterismColors.secondaryText) Spacer() } .padding(.horizontal) .frame(minHeight: AsterismLayout.minHitTarget)- .background(Color.secondary.opacity(0.1))+ // Neutral, not amber: requirement 8.6 reserves amber for states the+ // reader can act on, and there is nothing to tap here — the line+ // reports that a count may be stale.+ .constellationCard(cornerRadius: AsterismLayout.bannerRadius)+ .padding(.horizontal, 16)+ .padding(.vertical, 4) .accessibilityElement(children: .combine) .accessibilityIdentifier("diagnosis-refresh-failed-banner") }@@ -549,17 +626,11 @@ struct RecentEntryRow: View { Button { onResolveDuplicate(resolutionKey) } label: {+ // §7's pill recipe, shared with Teach per Decision 1. Text("Resolve")- .font(.caption.bold())- .foregroundStyle(AsterismColors.amberDark)- .padding(.horizontal, 8)- .padding(.vertical, 10)- .frame(minHeight: AsterismLayout.minHitTarget)- .background(AsterismColors.amberDark.opacity(0.15))- .clipShape(Capsule())+ .constellationPill(.attention) } .buttonStyle(.plain)- .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("resolve-duplicate-pill") .accessibilityLabel( row.duplicateRoute?.blockingWorkSet == nil@@ -573,43 +644,40 @@ struct RecentEntryRow: View { onTeach(row.id) } label: { Text(row.actionType == .teach ? "Teach" : "Re-teach")- .font(.caption.bold())- .foregroundStyle(AsterismColors.amberDark)- .padding(.horizontal, 8)- .padding(.vertical, 10)- .frame(minHeight: AsterismLayout.minHitTarget)- .background(AsterismColors.amberDark.opacity(0.15))- .clipShape(Capsule())+ .constellationPill(.attention) } .buttonStyle(.plain)- .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("teach-pill") .accessibilityLabel(row.actionType == .teach ? "Teach title pattern" : "Re-teach title pattern") } }- .padding(.vertical, 4)+ .padding(.vertical, 10)+ .padding(.horizontal, 12) .frame(minHeight: AsterismLayout.minHitTarget)- .overlay {- // Q38: driven by `attention`, not `isActionable`. An attention row is- // deliberately *not* actionable — its action was withdrawn because it- // could not succeed — so keying the edge to `isActionable` would leave- // exactly the rows that need marking unmarked.- if row.isActionable || row.attention != nil || row.duplicateRoute != nil {- RoundedRectangle(cornerRadius: 6)- .stroke(AsterismColors.amberDark.opacity(0.3), lineWidth: 1)- .allowsHitTesting(false)- }- }+ // §7's unparsed row: the ordinary card, with the amber border.+ //+ // Q38: driven by `attention`, not `isActionable`. An attention row is+ // deliberately *not* actionable — its action was withdrawn because it+ // could not succeed — so keying the edge to `isActionable` would leave+ // exactly the rows that need marking unmarked.+ .constellationCard(borderColor: needsAttention ? AsterismColors.attentionBorder : nil)+ }++ private var needsAttention: Bool {+ row.isActionable || row.attention != nil || row.duplicateRoute != nil } private var entryContent: some View { VStack(alignment: .leading, spacing: 4) { HStack { // Show Work display title if available, else capture title+ // Req 11.3: one line, ellipsis, never wrapped. Text(row.workDisplayTitle ?? row.captureTitle)- .font(AsterismTypography.serifTitle(15))+ .font(AsterismTypography.serifRowTitle) .lineLimit(1)- .foregroundStyle(row.isActionable ? AsterismColors.amberDark : .primary)+ .truncationMode(.tail)+ .foregroundStyle(+ row.isActionable ? AsterismColors.amberText : AsterismColors.primaryText) .accessibilityIdentifier("entry-title") Spacer()@@ -619,7 +687,7 @@ struct RecentEntryRow: View { if let chapterTitle = row.chapterTitle { Text(chapterTitle) .font(.caption)- .foregroundStyle(AsterismColors.cyanDark)+ .foregroundStyle(AsterismColors.cyan) .lineLimit(1) .accessibilityIdentifier("entry-chapter") }@@ -631,11 +699,11 @@ struct RecentEntryRow: View { HStack(spacing: 4) { Image(systemName: "exclamationmark.triangle") .font(.caption2)- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text(Self.attentionLabel(attention)) .font(.caption)- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) .lineLimit(2) } .accessibilityIdentifier("entry-attention")@@ -646,41 +714,47 @@ struct RecentEntryRow: View { HStack(spacing: 4) { Image(systemName: "exclamationmark.triangle") .font(.caption2)- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text(candidate) .font(.caption)- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) .lineLimit(1) } .accessibilityIdentifier("entry-unresolved-candidate") } HStack(spacing: 6) {- Text(row.hostname)- .font(.caption)- .foregroundStyle(.secondary)- .accessibilityIdentifier("entry-hostname")+ // Req 9.2: a row that showed the hostname as plain text shows+ // the glyph instead. The hostname is still spoken — the row+ // button's accessibility label names it.+ SiteGlyph(+ hostname: row.hostname,+ kind: row.workDisplayTitle == nil ? .unknown : .site,+ size: 18+ )+ .accessibilityIdentifier("entry-hostname") Spacer() + // §5: rating glyphs in rows never glow — plain accent text. if let rating = row.rating { Text(rating == .up ? "▲" : "▼") .font(.caption)- .foregroundStyle(rating == .up ? AsterismColors.cyanDark : AsterismColors.violetDark)+ .foregroundStyle(rating == .up ? AsterismColors.cyan : AsterismColors.violet) .accessibilityIdentifier("entry-rating") } Text(row.lastSharedAt, style: .time) .font(.caption2)- .foregroundStyle(.secondary)+ .foregroundStyle(AsterismColors.secondaryText) .accessibilityIdentifier("entry-time") } if !row.note.isEmpty { Text(row.note) .font(.caption)- .foregroundStyle(.tertiary)+ .foregroundStyle(AsterismColors.noteText) .lineLimit(2) .accessibilityIdentifier("entry-note-preview") }
diff --git a/Asterism/Asterism/Views/ReparseView.swift b/Asterism/Asterism/Views/ReparseView.swiftindex b37bc70..4265f6b 100644--- a/Asterism/Asterism/Views/ReparseView.swift+++ b/Asterism/Asterism/Views/ReparseView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// Re-parse sheet: shows before→after chapter, Work outcome,@@ -50,7 +51,7 @@ struct ReparseView: View { Form { // Before/After chapter if let projection = model.projection, let before = model.entryBefore {- Section("Chapter") {+ Section(header: ConstellationSectionHeader("Chapter", accent: .cyan)) { LabeledContent("Before") { Text(before.chapterTitle ?? "—") .font(.caption)@@ -65,7 +66,7 @@ struct ReparseView: View { } // Work outcome- Section("Work Assignment") {+ Section(header: ConstellationSectionHeader("Work Assignment", accent: .violet)) { if let workTitle = projection.projectedWorkTitle { LabeledContent("Projected Work") { Text(workTitle)@@ -93,7 +94,7 @@ struct ReparseView: View { // Protections if projection.chapterProtected || projection.assignmentProtected {- Section("Protections") {+ Section(header: ConstellationSectionHeader("Protections", accent: .violet)) { if projection.chapterProtected { Label("Chapter: manual protection (preserved)", systemImage: "lock.fill") .font(.caption)@@ -108,7 +109,7 @@ struct ReparseView: View { } // Provenance- Section("Projected Provenance") {+ Section(header: ConstellationSectionHeader("Projected Provenance", accent: .violet)) { LabeledContent("Chapter Provenance") { Text(provenanceLabel(projection.chapterProvenance)) .font(.caption)@@ -124,27 +125,27 @@ struct ReparseView: View { // Parse errors if let parseFailure = projection.parseFailure {- Section("Parse Error") {+ Section(header: ConstellationSectionHeader("Parse Error", accent: .violet)) { Text(String(describing: parseFailure)) .font(.caption)- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("reparse-parse-error") } } // Ambiguous candidates if model.hasAmbiguity {- Section("Ambiguous Work Candidates") {+ Section(header: ConstellationSectionHeader("Ambiguous Work Candidates", accent: .violet)) { Text("Multiple Works match the parsed title. Assignment will remain unresolved.") .font(.caption)- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) .accessibilityIdentifier("reparse-ambiguity-warning") } } // Works to create if !model.worksToCreate.isEmpty {- Section("Works to Create") {+ Section(header: ConstellationSectionHeader("Works to Create", accent: .violet)) { ForEach(model.worksToCreate, id: \.self) { title in Text(title) .font(.caption)@@ -155,23 +156,30 @@ struct ReparseView: View { } // Confirm / Reconfirm+ //+ // Req 9.1: whichever of the two is on screen is this sheet's one+ // gradient control — they are exclusive branches, never both. Section { if model.requiresReconfirmation { Button("Reconfirm") { Task { await model.reconfirm() } }- .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .buttonStyle(.constellationPrimary) .accessibilityIdentifier("reparse-reconfirm-button")+ .constellationListRow(+ insets: EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) Text("The projection was refreshed. Please review and reconfirm.") .font(.caption)- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) } else { Button("Confirm") { Task { await model.confirm() } }- .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .buttonStyle(.constellationPrimary) .accessibilityIdentifier("reparse-confirm-button")+ .constellationListRow(+ insets: EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } } }@@ -182,7 +190,7 @@ struct ReparseView: View { VStack(spacing: 16) { Image(systemName: "checkmark.circle.fill") .font(.largeTitle)- .foregroundStyle(.green)+ .foregroundStyle(AsterismColors.cyan) Text("Re-parse committed successfully.") Button("Done") { dismiss() } .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)@@ -196,7 +204,7 @@ struct ReparseView: View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle.fill") .font(.largeTitle)- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) Text("Re-parse invalidated") .font(.headline) Text(reason)@@ -216,7 +224,7 @@ struct ReparseView: View { VStack(spacing: 16) { Image(systemName: "xmark.circle.fill") .font(.largeTitle)- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) Text("Error") .font(.headline) Text(message)
diff --git a/Asterism/Asterism/Views/SettingsBackupImportView.swift b/Asterism/Asterism/Views/SettingsBackupImportView.swiftindex 92aa3cf..1192527 100644--- a/Asterism/Asterism/Views/SettingsBackupImportView.swift+++ b/Asterism/Asterism/Views/SettingsBackupImportView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI import UniformTypeIdentifiers @@ -93,7 +94,7 @@ struct SettingsBackupImportView: View { Button("Import") { Task { await model.confirmImport() } }- .buttonStyle(.borderedProminent)+ .buttonStyle(.constellationPrimary) .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("settings-confirm-import-button") @@ -122,7 +123,7 @@ struct SettingsBackupImportView: View { VStack(spacing: 12) { HStack { Image(systemName: "checkmark.circle.fill")- .foregroundStyle(.green)+ .foregroundStyle(AsterismColors.cyan) Text("Import complete") } Text("Your library now holds \(counts.entries) entries and \(counts.works) works.")@@ -142,7 +143,7 @@ struct SettingsBackupImportView: View { VStack(spacing: 12) { HStack { Image(systemName: "exclamationmark.triangle.fill")- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) Text("Import Failed") } Text(message)
diff --git a/Asterism/Asterism/Views/SettingsView.swift b/Asterism/Asterism/Views/SettingsView.swiftindex 7a0d0f2..bc2e923 100644--- a/Asterism/Asterism/Views/SettingsView.swift+++ b/Asterism/Asterism/Views/SettingsView.swift@@ -1,3 +1,5 @@+import AsterismCore+import ConstellationKit import SwiftUI /// Settings surface presenting backup export and import actions.@@ -26,19 +28,30 @@ struct SettingsView: View { /// and the one action it names — import the backup again — is the section /// it sits beside. private let interruptedImportNotice: String?+ /// Req 6.1's list. A plain `let` like `diagnosticsModel`: the screen it+ /// pushes owns the loaded state, so nothing here needs to survive a+ /// re-render.+ private let sitesModel: SitesListModel?+ /// Built by the host, because the site screen's re-teach route has to close+ /// Settings before the teaching sheet can present.+ private let siteDetailModel: (@MainActor (SiteSnapshot) -> SiteDetailModel?)? init( model: SettingsBackupModel, importModel: SettingsBackupImportModel? = nil, diagnosticsModel: LibraryDiagnosticsModel? = nil, syncModel: SettingsSyncModel? = nil,- interruptedImportNotice: String? = nil+ interruptedImportNotice: String? = nil,+ sitesModel: SitesListModel? = nil,+ siteDetailModel: (@MainActor (SiteSnapshot) -> SiteDetailModel?)? = nil ) { _model = State(initialValue: model) _importModel = State(initialValue: importModel) _syncModel = State(initialValue: syncModel) self.diagnosticsModel = diagnosticsModel self.interruptedImportNotice = interruptedImportNotice+ self.sitesModel = sitesModel+ self.siteDetailModel = siteDetailModel } var body: some View {@@ -53,14 +66,16 @@ struct SettingsView: View { backupRow diagnosticsRow } header: {- Text("Data")+ ConstellationSectionHeader("Data", accent: .violet) } + sitesSection+ if let importModel { Section { SettingsBackupImportView(model: importModel) } header: {- Text("Import")+ ConstellationSectionHeader("Import", accent: .violet) } } }@@ -134,7 +149,7 @@ struct SettingsView: View { .accessibilityIdentifier("settings-sync-counts-line") } } header: {- Text("iCloud")+ ConstellationSectionHeader("iCloud") } } }@@ -145,7 +160,7 @@ struct SettingsView: View { Image(systemName: failure.isBannerWorthy ? "exclamationmark.triangle.fill" : "exclamationmark.circle") .font(.caption)- .foregroundStyle(failure.isBannerWorthy ? AsterismColors.amberDark : .secondary)+ .foregroundStyle(failure.isBannerWorthy ? AsterismColors.amberText : .secondary) .accessibilityHidden(true) Text(failure.condition) .font(.callout)@@ -174,7 +189,7 @@ struct SettingsView: View { HStack(alignment: .firstTextBaseline, spacing: 6) { Image(systemName: "exclamationmark.circle") .font(.caption)- .foregroundStyle(AsterismColors.amberDark)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text(interruptedImportNotice) .font(.callout)@@ -184,6 +199,26 @@ struct SettingsView: View { } } + // MARK: - Sites (Req 6.1)++ /// After Data: sync and the backup actions are what a reader opens Settings+ /// for; the site list is where they go when a *particular* site is wrong.+ @ViewBuilder+ private var sitesSection: some View {+ if let sitesModel, let siteDetailModel {+ Section {+ NavigationLink {+ SitesListView(model: sitesModel, detailModel: siteDetailModel)+ } label: {+ Label("Sites", systemImage: "globe")+ }+ .accessibilityIdentifier("settings-sites-button")+ } header: {+ ConstellationSectionHeader("Sites", accent: .violet)+ }+ }+ }+ // MARK: - Library Check Row (Req 4.2) /// Reachable whether or not anything is diagnosed: the reader should be able@@ -226,7 +261,7 @@ struct SettingsView: View { case .sharing: HStack { Image(systemName: "checkmark.circle.fill")- .foregroundStyle(AsterismColors.cyanDark)+ .foregroundStyle(AsterismColors.cyan) Text("Backup ready") .foregroundStyle(.secondary) }@@ -236,7 +271,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "exclamationmark.triangle.fill")- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) Text("Backup Failed") } .accessibilityIdentifier("settings-backup-failed")@@ -291,20 +326,3 @@ struct SettingsView: View { } } }--// MARK: - System Share Sheet (UIKit bridge)--/// Wraps UIActivityViewController for presenting the backup file through the system share interface.-private struct ShareSheet: UIViewControllerRepresentable {- let fileURL: URL-- func makeUIViewController(context: Context) -> UIActivityViewController {- let controller = UIActivityViewController(- activityItems: [fileURL],- applicationActivities: nil- )- return controller- }-- func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}-}
diff --git a/Asterism/Asterism/Views/ShareSheet.swift b/Asterism/Asterism/Views/ShareSheet.swiftnew file mode 100644index 0000000..90f2bb6--- /dev/null+++ b/Asterism/Asterism/Views/ShareSheet.swift@@ -0,0 +1,54 @@+import SwiftUI++/// Wraps `UIActivityViewController` for handing a staged file to the system+/// share interface.+///+/// Lifted out of `SettingsView`, where it was `private`, when markdown export+/// gained its own share flows (Reqs 1.5, 2.4): Settings, Work detail, and Entry+/// detail all present a staged file, and one wrapper is what keeps the three+/// presentations identical.+struct ShareSheet: UIViewControllerRepresentable {+ let fileURL: URL++ func makeUIViewController(context: Context) -> UIActivityViewController {+ UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)+ }++ func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}+}++/// Presents a staged markdown export in the system share sheet, driven by the+/// export model's own state (Reqs 1.5, 2.4).+///+/// One modifier rather than a `@State` mirror per detail screen. What decides+/// whether the sheet is up is `model.state == .sharing`; a second copy of that+/// kept in step by `onChange` is a copy that can disagree with it. The derived+/// binding is the presented-value shape both screens already use for their+/// deletion dialogs, and its `set` is the dismissal — which covers send and+/// cancel alike, because the system share sheet reports neither.+private struct MarkdownExportShare: ViewModifier {+ let model: MarkdownExportModel?+ let sheetIdentifier: String++ func body(content: Content) -> some View {+ content.sheet(+ isPresented: Binding(+ get: { model?.state == .sharing },+ set: { if !$0 { model?.handleShareCompletion() } })+ ) {+ if let url = model?.exportedFileURL {+ ShareSheet(fileURL: url)+ .accessibilityIdentifier(sheetIdentifier)+ }+ }+ }+}++extension View {+ /// Hands `model`'s staged file to the share sheet while it is sharing.+ func markdownExportShare(+ model: MarkdownExportModel?, sheetIdentifier: String+ ) -> some View {+ modifier(MarkdownExportShare(model: model, sheetIdentifier: sheetIdentifier))+ }+}
diff --git a/Asterism/Asterism/Views/SitesView.swift b/Asterism/Asterism/Views/SitesView.swiftnew file mode 100644index 0000000..2575213--- /dev/null+++ b/Asterism/Asterism/Views/SitesView.swift@@ -0,0 +1,185 @@+import AsterismCore+import ConstellationKit+import SwiftUI++/// The Sites list (Req 6.1): every site the library knows, taught or not.+///+/// Pushed from Settings, like Check Library — Settings sub-screens are pushes.+struct SitesListView: View {+ @State private var model: SitesListModel+ /// Built by the host, because the detail screen's re-teach route has to+ /// close Settings before a sheet can present.+ let detailModel: @MainActor (SiteSnapshot) -> SiteDetailModel?++ init(+ model: SitesListModel,+ detailModel: @escaping @MainActor (SiteSnapshot) -> SiteDetailModel?+ ) {+ _model = State(initialValue: model)+ self.detailModel = detailModel+ }++ var body: some View {+ List {+ switch model.state {+ case .loading:+ ProgressView("Loading…")+ .accessibilityIdentifier("settings-sites-loading")+ case .error(let message):+ Text(message)+ .font(.callout)+ .foregroundStyle(.secondary)+ .accessibilityIdentifier("settings-sites-error")+ case .ready:+ if model.rows.isEmpty {+ Text(model.emptyMessage)+ .font(.callout)+ .foregroundStyle(.secondary)+ .accessibilityIdentifier("settings-sites-empty")+ } else {+ ForEach(model.rows) { row in+ siteRow(row)+ }+ }+ }+ }+ .navigationTitle("Sites")+ .accessibilityIdentifier("settings-sites-list")+ .task { await model.load() }+ }++ private func siteRow(_ row: SitesListModel.Row) -> some View {+ NavigationLink {+ if let detail = detailModel(row.site) {+ SiteDetailView(model: detail)+ }+ } label: {+ HStack(spacing: 10) {+ SiteGlyph(hostname: row.hostname, kind: glyphKind(row.site.mode))+ .accessibilityHidden(true)+ VStack(alignment: .leading, spacing: 2) {+ Text(row.displayName)+ .font(AsterismTypography.serifRowTitle)+ .lineLimit(1)+ .truncationMode(.tail)+ if row.showsHostnameSeparately {+ Text(row.hostname)+ .font(.caption)+ .foregroundStyle(.secondary)+ .lineLimit(1)+ }+ }+ Spacer()+ Text(row.modeLabel)+ .font(.caption2)+ .foregroundStyle(.secondary)+ }+ .frame(minHeight: AsterismLayout.minHitTarget)+ }+ .accessibilityIdentifier("site-row-\(row.hostname)")+ .accessibilityLabel("\(row.displayName), \(row.modeLabel)")+ }++ /// An untaught site has nothing behind it yet and an articles site is+ /// deliberately not a work — both read as the flat, dim glyphs (9.2).+ private func glyphKind(_ mode: SiteMode) -> SiteGlyphKind {+ switch mode {+ case .untaught: .unknown+ case .articles: .articles+ case .taught: .site+ }+ }+}++/// One site's screen (Reqs 6.2–6.4): what mode it is in, the re-teach route,+/// and the one-way switch to articles.+struct SiteDetailView: View {+ @State private var model: SiteDetailModel+ @Environment(\.dismiss) private var dismiss++ init(model: SiteDetailModel) {+ _model = State(initialValue: model)+ }++ var body: some View {+ List {+ Section {+ LabeledContent("Mode") { Text(model.modeLabel) }+ .accessibilityIdentifier("site-detail-mode")+ Text(model.modeExplanation)+ .font(.caption)+ .foregroundStyle(.secondary)+ .accessibilityIdentifier("site-detail-mode-explanation")+ } header: {+ ConstellationSectionHeader(model.site.hostname)+ }++ Section {+ // Req 9.1: this screen's one gradient control. The articles+ // switch below it is deliberately not — it is a one-way change+ // the reader should have to mean.+ Button(model.reteachLabel) { model.reteach() }+ .buttonStyle(.constellationPrimary)+ .disabled(!model.canReteach)+ .accessibilityIdentifier("site-detail-reteach-button")++ // Never left to a greyed-out button on its own: a control that+ // does nothing and says nothing is the dead end this milestone+ // removes everywhere else.+ if let message = model.reteachUnavailableMessage {+ Text(message)+ .font(.caption)+ .foregroundStyle(.secondary)+ .accessibilityIdentifier("site-detail-reteach-unavailable")+ }++ if model.canSwitchToArticles {+ Button("Treat every page as an article") {+ Task { await model.requestArticlesSwitch() }+ }+ .buttonStyle(.constellationSecondary)+ .disabled(model.isSubmitting)+ .accessibilityIdentifier("site-detail-articles-button")+ }+ }++ if let message = model.errorMessage {+ Section {+ Text(message)+ .font(.callout)+ .foregroundStyle(AsterismColors.amberText)+ .accessibilityIdentifier("site-detail-error")+ }+ }+ }+ .navigationTitle(model.site.displayName)+ .navigationBarTitleDisplayMode(.inline)+ .accessibilityIdentifier("site-detail-view")+ // The consequences, then the choice — the same shape teach mode's own+ // articles confirmation has, and the same contracts underneath (6.4).+ .confirmationDialog(+ "Treat every page as an article?",+ isPresented: Binding(+ get: { model.articlesPrompt != nil },+ set: { if !$0 { model.cancelArticlesSwitch() } }),+ presenting: model.articlesPrompt+ ) { prompt in+ // The prompt is taken as a parameter, not re-read from the model:+ // SwiftUI runs the dismissal below before this action (see+ // `SiteDetailModel.ArticlesPrompt`).+ Button("Turn on Articles mode", role: .destructive) {+ Task { await model.confirmArticlesSwitch(prompt) }+ }+ .accessibilityIdentifier("site-detail-articles-confirm")+ Button("Cancel", role: .cancel) { model.cancelArticlesSwitch() }+ .accessibilityIdentifier("site-detail-articles-cancel")+ } message: { prompt in+ Text(prompt.message)+ }+ // The screen holds an immutable snapshot, so once the mode has changed+ // everything on it describes the site as it was. Back to the list.+ .onChange(of: model.didSwitchToArticles) { _, switched in+ if switched { dismiss() }+ }+ }+}
diff --git a/Asterism/Asterism/Views/TeachingComponents.swift b/Asterism/Asterism/Views/TeachingComponents.swiftindex 484408e..e55cfa4 100644--- a/Asterism/Asterism/Views/TeachingComponents.swift+++ b/Asterism/Asterism/Views/TeachingComponents.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// One chip of the composed surface's two-granularity title selector (Req 8.3,@@ -20,13 +21,12 @@ struct TitleChipView: View { HStack(spacing: 4) { Text(roleSymbol) .font(.caption.monospaced().bold())- .foregroundStyle(roleColor) Text(text) .font(.subheadline) .lineLimit(1)+ // §7: an ignored chip is dim, half-opacity, struck out.+ .strikethrough(role == .ignore) }- .padding(.horizontal, 12)- .padding(.vertical, 8) .frame(minHeight: AsterismLayout.minHitTarget) } .buttonStyle(.plain)@@ -44,7 +44,7 @@ struct TitleChipView: View { Button(action: onSubdivide) { Image(systemName: "scissors") .font(.caption)- .padding(.horizontal, 10)+ .padding(.leading, 10) .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget) } .buttonStyle(.plain)@@ -53,9 +53,19 @@ struct TitleChipView: View { } } .fixedSize(horizontal: true, vertical: false)- .background(roleColor.opacity(0.12))- .clipShape(Capsule())- .overlay(Capsule().stroke(roleColor.opacity(0.4), lineWidth: 1))+ // §7's teach chip. A selected chip is the only one that glows (§5).+ .constellationChip(role: chipRole, isSelected: role != .ignore)+ }++ /// Q36: the style guide's §7 mapping — chapter chips are amber, work chips+ /// cyan. The shipped code had these inverted; the flip lands here, with the+ /// chip recipe.+ private var chipRole: ConstellationChipRole {+ switch role {+ case .chapter: .chapter+ case .work: .work+ case .ignore: .ignored+ } } private var roleSymbol: String {@@ -74,11 +84,13 @@ struct TitleChipView: View { } } + /// The divider between the chip and its split control. Matches the chip's+ /// own accent — amber text on dark, darkened on light (requirement 11.4). private var roleColor: Color { switch role {- case .chapter: AsterismColors.cyanDark- case .work: AsterismColors.amberDark- case .ignore: .secondary+ case .chapter: AsterismColors.amberText+ case .work: AsterismColors.cyan+ case .ignore: AsterismColors.secondaryText } } }
diff --git a/Asterism/Asterism/Views/WorkDetailView.swift b/Asterism/Asterism/Views/WorkDetailView.swiftindex 9f03b97..8a44e99 100644--- a/Asterism/Asterism/Views/WorkDetailView.swift+++ b/Asterism/Asterism/Views/WorkDetailView.swift@@ -1,20 +1,46 @@ import AsterismCore+import ConstellationKit import SwiftUI -/// Work detail: shows metadata, assigned entries, and editing controls.+/// Work detail in the §6 shape (Req 5.1): header, rating pulse, "Open last+/// noted chapter", generic notes, then the chapter-notes list.+///+/// The screen is presentation-first. What §6 places on it — the title, the type+/// and genre tags, the generic notes — stays editable in place (Q20, Req 5.6);+/// the Work URL and URL-identity machinery, which §6's layout has no home for,+/// moves behind **Edit details**. Save is a toolbar confirmation that appears+/// only while a draft differs, and every other action lives in the one toolbar+/// menu (Req 5.5) — which is also the only route to Merge. struct WorkDetailView: View { @State private var model: WorkDetailModel @Environment(\.dismiss) private var dismiss+ @Environment(\.openURL) private var openURL @State private var activeMergeModel: WorkMergeModel?+ @State private var showingEditDetails = false @State private var showingURLIdentityReview = false @State private var showingURLReteach = false+ /// Req 2.4's staging model, `@State`-owned so the staged file survives a+ /// re-render (the `SettingsView` precedent).+ @State private var exportModel: MarkdownExportModel? /// Req 9.2's route out of a torn Work, or nil where the caller has no sheet /// to open. Entry detail's shape, for the same reason it has one. let onResolveDuplicate: (() -> Void)?+ /// Requirement 8.1 puts the sky behind tab-stack content only. This screen+ /// is pushed on the Works stack *and* presented as a sheet by the Merge+ /// route, and a sheet is its own glass layer (§4) — so the host says which+ /// it is rather than the screen assuming.+ let showsSky: Bool - init(model: WorkDetailModel, onResolveDuplicate: (() -> Void)? = nil) {+ init(+ model: WorkDetailModel,+ onResolveDuplicate: (() -> Void)? = nil,+ exportModel: MarkdownExportModel? = nil,+ showsSky: Bool = true+ ) { _model = State(initialValue: model)+ _exportModel = State(initialValue: exportModel) self.onResolveDuplicate = onResolveDuplicate+ self.showsSky = showsSky } var body: some View {@@ -32,50 +58,465 @@ struct WorkDetailView: View { } } .navigationTitle("Work")+ .background { if showsSky { ConstellationBackground() } } .task { await model.load() }+ // A committed deletion takes the screen with it — there is nothing left+ // to show and nothing to go back to.+ .onChange(of: model.isDeleted) { _, deleted in+ if deleted { dismiss() }+ } } + // MARK: - The §6 layout+ @ViewBuilder private func workContent(_ work: WorkSnapshot) -> some View {- Form {+ List { if model.isReadOnly { duplicateReviewSection } - Section("Metadata") {- TextField("Title", text: $model.draftTitle)- .font(AsterismTypography.serifTitle(17))- .disabled(model.isReadOnly)- .accessibilityIdentifier("work-detail-title-field")+ headerSection(work)+ pulseSection+ openLastNotedSection+ notesSection+ chapterSection - LabeledContent("Site") {- Text(work.siteHostname)+ if let errorMessage = model.errorMessage {+ Section {+ // Q37: a save failure is amber, not system red — the+ // palette has no error colour and §11 forbids a fourth hue.+ Text(errorMessage)+ .foregroundStyle(AsterismColors.amberText)+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ .constellationCard(borderColor: AsterismColors.attentionBorder)+ .constellationListRow()+ .accessibilityIdentifier("work-detail-error")+ }+ }+ }+ // Req 8.1: the Works tab's sky shows through the pushed screen too.+ .scrollContentBackground(.hidden)+ .toolbar {+ // Req 5.5: exactly four items, and the only way to Merge.+ ToolbarItem(placement: .topBarTrailing) { actionsMenu }+ // Only while there is something to save (Req 5.6's in-place edits).+ if model.hasUnsavedChanges {+ ToolbarItem(placement: .confirmationAction) {+ Button("Save") { Task { await model.save() } }+ .disabled(model.state == .submitting || model.isReadOnly)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("work-detail-save-button") }- .accessibilityIdentifier("work-detail-hostname")+ }+ }+ .sheet(+ isPresented: Binding(+ get: { activeMergeModel != nil },+ set: { if !$0 { handleMergeDismissed() } }+ )+ ) {+ if let mergeModel = activeMergeModel {+ WorkMergeView(model: mergeModel)+ }+ }+ .sheet(isPresented: $showingEditDetails) {+ NavigationStack {+ WorkEditDetailsView(+ model: model,+ onReviewURLIdentity: {+ showingEditDetails = false+ showingURLIdentityReview = true+ },+ onReteachURLRule: {+ showingEditDetails = false+ showingURLReteach = true+ },+ onDone: { showingEditDetails = false })+ }+ }+ .sheet(isPresented: $showingURLIdentityReview) {+ if let reviewModel = model.reviewModel() {+ URLIdentityReviewView(model: reviewModel)+ }+ }+ .sheet(isPresented: $showingURLReteach) {+ if let reteachModel = model.composedURLReteachModel() {+ ComposedTeachingContainerView(model: reteachModel)+ }+ }+ .markdownExportShare(+ model: exportModel, sheetIdentifier: "work-detail-export-share-sheet")+ // Req 7.1's two dispositions plus cancel. `presenting:` hands the+ // buttons the projection the dialog was built from, so the count in the+ // message and the contract the commit takes are one value — and the+ // buttons take it as a parameter, because SwiftUI runs the dismissal+ // below (which clears the model) before it runs the tapped action.+ .confirmationDialog(+ "Delete this work?",+ isPresented: Binding(+ get: { model.deletionPrompt != nil },+ set: { if !$0 { model.cancelDelete() } }),+ presenting: model.deletionPrompt+ ) { prompt in+ Button("Delete its notes too", role: .destructive) {+ Task { await model.chooseDeletion(prompt, disposition: .deleteEntries) }+ }+ .accessibilityIdentifier("work-detail-delete-entries")+ Button("Delete work, keep its notes") {+ Task { await model.chooseDeletion(prompt, disposition: .detachEntries) }+ }+ .accessibilityIdentifier("work-detail-delete-detach")+ Button("Cancel", role: .cancel) { model.cancelDelete() }+ .accessibilityIdentifier("work-detail-delete-cancel")+ } message: { prompt in+ Text(prompt.message)+ }+ // Req 7.2's disclosure, on **both** dispositions: deleting destroys+ // authored copies and detaching authors over them. The confirmed value+ // travels as a parameter — SwiftUI clears the binding before the action+ // runs, so re-reading the model here would disclose nothing.+ .alert(+ "Some of these notes have copies that differ",+ isPresented: Binding(+ get: { model.deleteDisclosure != nil },+ set: { if !$0 { model.cancelDeletionDisclosure() } }),+ presenting: model.deleteDisclosure+ ) { disclosure in+ Button("Continue", role: .destructive) {+ Task { await model.confirmDeletion(disclosure) }+ }+ .accessibilityIdentifier("work-detail-delete-disclosure-confirm")+ Button("Cancel", role: .cancel) { model.cancelDeletionDisclosure() }+ .accessibilityIdentifier("work-detail-delete-disclosure-cancel")+ } message: { disclosure in+ Text(disclosureMessage(disclosure))+ }+ }++ /// Header: site glyph, the editable title, the site line with its URL+ /// identity, and the type and genre tags (Req 5.1, 5.6).+ @ViewBuilder+ private func headerSection(_ work: WorkSnapshot) -> some View {+ Section {+ HStack(alignment: .top, spacing: 12) {+ SiteGlyph(hostname: work.siteHostname, size: 44)+ .accessibilityHidden(true)++ VStack(alignment: .leading, spacing: 6) {+ TextField("Title", text: $model.draftTitle)+ .font(AsterismTypography.serifHeading)+ .foregroundStyle(AsterismColors.primaryText)+ .disabled(model.isReadOnly)+ .accessibilityIdentifier("work-detail-title-field") - Picker("Type", selection: $model.draftType) {- ForEach(WorkType.allCases, id: \.self) { type in- Text(type.rawValue).tag(type)+ HStack(spacing: 6) {+ Text(work.siteHostname)+ .font(.caption)+ .foregroundStyle(AsterismColors.secondaryText)+ .lineLimit(1)+ .accessibilityIdentifier("work-detail-hostname")+ // §3: URL chips are monospace and dim.+ if let identity = work.urlIdentity, !identity.isEmpty {+ Text(identity)+ .font(AsterismTypography.mono)+ .foregroundStyle(AsterismColors.secondaryText)+ .lineLimit(1)+ .accessibilityIdentifier("work-detail-url-identity")+ } } }+ }+ .padding(12)+ .constellationCard()+ .constellationListRow()++ Picker("Type", selection: $model.draftType) {+ ForEach(WorkType.allCases, id: \.self) { type in+ Text(type.rawValue).tag(type)+ }+ }+ .disabled(model.isReadOnly)+ .accessibilityIdentifier("work-detail-type-picker")+ .padding(.horizontal, 12)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .constellationCard()+ .constellationListRow()++ TextField(+ "Genre tags (comma-separated)",+ text: Binding(+ get: { model.draftTags.joined(separator: ", ") },+ set: {+ model.draftTags = $0.split(separator: ",")+ .map { String($0).trimmingCharacters(in: .whitespaces) }+ }+ )+ )+ .disabled(model.isReadOnly)+ .accessibilityIdentifier("work-detail-tags-field")+ .padding(.horizontal, 12)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .constellationCard()+ .constellationListRow()+ }+ }++ /// Req 5.2: three counts over logical records, no drill-down.+ ///+ /// §7's rating pulse: three equal cards, the ▲ count cyan, the ▼ count+ /// violet, the notes count neutral.+ private var pulseSection: some View {+ Section {+ HStack(spacing: 10) {+ pulseCount(+ String(model.pulse.notes),+ tint: AsterismColors.primaryText,+ label: "notes",+ identifier: "work-detail-pulse-notes")+ pulseCount(+ "▲ \(model.pulse.up)",+ tint: AsterismColors.cyan,+ label: "rated up",+ identifier: "work-detail-pulse-up")+ pulseCount(+ "▼ \(model.pulse.down)",+ tint: AsterismColors.violet,+ label: "rated down",+ identifier: "work-detail-pulse-down")+ }+ // `children: .contain` before the identifier, or the group's+ // identifier propagates down and overwrites each card's own —+ // the same trap `ComposedTeachingView`'s acknowledgment panel+ // records. The three cards have to stay separately addressable.+ .accessibilityElement(children: .contain)+ .accessibilityIdentifier("work-detail-pulse")+ .constellationListRow()+ }+ }++ private func pulseCount(+ _ text: String,+ tint: Color,+ label: String,+ identifier: String+ ) -> some View {+ VStack(spacing: 2) {+ Text(text)+ .font(.headline.weight(.heavy))+ .foregroundStyle(tint)+ .lineLimit(1)+ Text(label)+ .font(.caption2)+ .foregroundStyle(AsterismColors.secondaryText)+ .lineLimit(1)+ }+ .frame(maxWidth: .infinity)+ .padding(.vertical, 12)+ .constellationCard()+ .accessibilityElement(children: .combine)+ .accessibilityIdentifier(identifier)+ }++ /// Req 5.3. Hidden entirely where the work has no entries — the repository+ /// decides that by returning no URL, so the screen never has to guess.+ @ViewBuilder+ private var openLastNotedSection: some View {+ if let last = model.lastNotedURLString, let url = URL(string: last) {+ Section {+ // Req 9.1: this screen's one gradient control. Every other+ // action on it is a toolbar item or a secondary button.+ Button("Open last noted chapter") { openURL(url) }+ .buttonStyle(.constellationPrimary)+ .accessibilityIdentifier("work-detail-open-last-noted")+ .accessibilityLabel("Open the most recently noted chapter")+ .constellationListRow(+ insets: EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))+ }+ }+ }++ private var notesSection: some View {+ Section {+ TextField("Notes", text: $model.draftNotes, axis: .vertical)+ .lineLimit(3...6) .disabled(model.isReadOnly)- .accessibilityIdentifier("work-detail-type-picker")+ .accessibilityIdentifier("work-detail-notes-field")+ .padding(12)+ .constellationCard()+ .constellationListRow()+ } header: {+ ConstellationSectionHeader("Notes")+ }+ } - TextField("Notes", text: $model.draftNotes, axis: .vertical)- .lineLimit(3...6)- .disabled(model.isReadOnly)- .accessibilityIdentifier("work-detail-notes-field")+ /// Req 5.4: the chapter-notes list, from `WorkChapterRow` — display titles+ /// cleaned by the repository, not by this view.+ @ViewBuilder+ private var chapterSection: some View {+ if !model.chapterRows.isEmpty {+ Section {+ ForEach(model.chapterRows) { row in+ WorkChapterRowView(row: row)+ .accessibilityIdentifier("work-detail-entry")+ .accessibilityLabel("Note on \(row.displayTitle)")+ .constellationListRow()+ }+ } header: {+ ConstellationSectionHeader("Chapter Notes", accent: .violet) }+ }+ }++ // MARK: - Toolbar menu (Req 5.5)++ private var actionsMenu: some View {+ Menu {+ if exportModel != nil {+ Button("Export") { Task { await exportModel?.startExport() } }+ .accessibilityIdentifier("work-detail-export-button")+ }+ Button("Merge into…") { activeMergeModel = model.mergeModel() }+ .accessibilityIdentifier("work-detail-merge-button")+ Button("Edit details") { showingEditDetails = true }+ .accessibilityIdentifier("work-detail-edit-details-button")+ Button("Delete work", role: .destructive) {+ Task { await model.requestDelete() }+ }+ .disabled(model.isReadOnly)+ .accessibilityIdentifier("work-detail-delete-button")+ } label: {+ Image(systemName: "ellipsis.circle")+ .frame(+ minWidth: AsterismLayout.minHitTarget,+ minHeight: AsterismLayout.minHitTarget)+ }+ .disabled(model.state == .submitting)+ .accessibilityIdentifier("work-detail-menu")+ .accessibilityLabel("Work actions")+ }++ // MARK: - Torn Work notice++ /// Req 2.8 and Req 9.2 on this screen, mirroring Entry detail: say plainly+ /// why the fields are off, and offer the route that turns them back on.+ private var duplicateReviewSection: some View {+ Section {+ VStack(alignment: .leading, spacing: 8) {+ Text("This work arrived \(model.rowCount) times and the copies differ.")+ .font(.callout)+ .foregroundStyle(AsterismColors.primaryText)+ Text("Editing is off until you choose which one to keep — nothing has been lost.")+ .font(.caption)+ .foregroundStyle(AsterismColors.secondaryText)+ if let onResolveDuplicate {+ // Secondary, not the gradient: this screen's one primary is+ // "Open last noted chapter" (9.1).+ Button("Resolve copies") { onResolveDuplicate() }+ .buttonStyle(.constellationSecondary)+ .accessibilityIdentifier("work-detail-resolve-duplicate-button")+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ // Decision 1: duplicate review is an actionable-attention surface,+ // so it wears the amber border.+ .constellationCard(borderColor: AsterismColors.attentionBorder)+ .constellationListRow()+ .accessibilityIdentifier("work-detail-duplicate-notice")+ }+ } - Section("Work URL") {+ // MARK: - Lifecycle helpers++ /// A committed merge deletes this source Work — leave the detail screen.+ /// Otherwise just reload the unchanged Work.+ private func handleMergeDismissed() {+ let committed = activeMergeModel?.state == .committed+ activeMergeModel = nil+ if committed {+ dismiss()+ } else {+ Task { await model.load() }+ }+ }++ private func disclosureMessage(_ disclosure: WorkDetailModel.WorkDeleteDisclosure) -> String {+ let opening = disclosure.disposition == .deleteEntries+ ? "All \(disclosure.variants.count) of these copies go, and they cannot be recovered:"+ : "Detaching writes over all \(disclosure.variants.count) of these copies:"+ return ([opening] + disclosure.lines).joined(separator: "\n\n")+ }+}++/// One row of the chapter-notes list (Req 5.4): rating glyph, chapter title,+/// note preview, date.+///+/// Its own row type rather than `UnattachedEntryRow`: that one renders the raw+/// capture title, which is exactly what 5.4 says this list must not do.+struct WorkChapterRowView: View {+ let row: WorkChapterRow++ var body: some View {+ VStack(alignment: .leading, spacing: 4) {+ HStack(spacing: 6) {+ if let rating = row.rating {+ Text(rating == .up ? "▲" : "▼")+ .font(.caption)+ .foregroundStyle(+ rating == .up ? AsterismColors.cyan : AsterismColors.violet)+ .accessibilityIdentifier("work-detail-entry-rating")+ }+ Text(row.displayTitle)+ .font(AsterismTypography.serifRowTitle)+ .lineLimit(1)+ .truncationMode(.tail)+ .foregroundStyle(AsterismColors.primaryText)+ .accessibilityIdentifier("work-detail-entry-title")+ Spacer()+ Text(row.lastSharedAt, style: .date)+ .font(.caption2)+ .foregroundStyle(AsterismColors.secondaryText)+ }++ if !row.note.isEmpty {+ Text(row.note)+ .font(.caption)+ .foregroundStyle(AsterismColors.noteText)+ .lineLimit(2)+ .accessibilityIdentifier("work-detail-entry-note")+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(.vertical, 10)+ .padding(.horizontal, 12)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .constellationCard()+ }+}++/// The Edit-details sheet (Q20): the Work URL and URL-identity machinery §6's+/// layout has no home for. A view split only — the model's draft and commit+/// surface is unchanged.+struct WorkEditDetailsView: View {+ @Bindable var model: WorkDetailModel+ let onReviewURLIdentity: () -> Void+ let onReteachURLRule: () -> Void+ let onDone: () -> Void++ var body: some View {+ Form {+ Section { if case .available(let candidate) = model.workURLCandidate { LabeledContent("Suggested") { Text(candidate.value) .textSelection(.enabled) }+ // Req 9.1: the Edit-details sheet's one gradient control. Button(WorkURLDetailPresentation.confirmLabel) { Task { await model.confirmWorkURLCandidate() } }+ .buttonStyle(.constellationPrimary) .disabled(model.isWorkURLSubmitting || model.isReadOnly)- .frame(minHeight: WorkURLDetailPresentation.minimumHitTarget) .accessibilityIdentifier(WorkURLDetailPresentation.confirmIdentifier) } @@ -110,133 +551,38 @@ struct WorkDetailView: View { .foregroundStyle(.secondary) .accessibilityIdentifier("work-detail-url-status") }- }-- Section("Tags") {- // Simple comma-separated tag editing- TextField(- "Genre tags (comma-separated)",- text: Binding(- get: { model.draftTags.joined(separator: ", ") },- set: { model.draftTags = $0.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } }- )- )- .disabled(model.isReadOnly)- .accessibilityIdentifier("work-detail-tags-field")+ } header: {+ ConstellationSectionHeader("Work URL") } if model.supportsURLIdentityReview {- Section("URL Identity") {- Button("Review URL identity") {- showingURLIdentityReview = true- }- .disabled(model.state == .submitting)- .frame(minHeight: AsterismLayout.minHitTarget)- .accessibilityIdentifier("work-detail-review-url-identity")- .accessibilityLabel("Review URL identity evidence and conflicts for this Work's site")-- Button("Re-teach URL rule") {- showingURLReteach = true- }- .disabled(model.state == .submitting)- .frame(minHeight: AsterismLayout.minHitTarget)- .accessibilityIdentifier("work-detail-reteach-url")- .accessibilityLabel("Re-teach the URL rule with the URL details expanded")- }- }-- if !work.entries.isEmpty {- Section("Entries (\(work.entries.count))") {- ForEach(work.entries, id: \.id) { entry in- UnattachedEntryRow(entry: entry)- .accessibilityIdentifier("work-detail-entry")- .accessibilityLabel("Assigned entry: \(entry.captureTitle)")- }- }- }-- if let errorMessage = model.errorMessage { Section {- Text(errorMessage)- .foregroundStyle(.red)- .accessibilityIdentifier("work-detail-error")- }- }-- Section {- Button("Save") {- Task { await model.save() }- }- .disabled(model.state == .submitting || model.isReadOnly)- .frame(minHeight: AsterismLayout.minHitTarget)- .accessibilityIdentifier("work-detail-save-button")+ Button("Review URL identity") { onReviewURLIdentity() }+ .disabled(model.state == .submitting)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("work-detail-review-url-identity")+ .accessibilityLabel(+ "Review URL identity evidence and conflicts for this Work's site") - Button("Merge into…") {- activeMergeModel = model.mergeModel()+ Button("Re-teach URL rule") { onReteachURLRule() }+ .disabled(model.state == .submitting)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("work-detail-reteach-url")+ .accessibilityLabel("Re-teach the URL rule with the URL details expanded")+ } header: {+ ConstellationSectionHeader("URL Identity", accent: .violet) }- .disabled(model.state == .submitting)- .frame(minHeight: AsterismLayout.minHitTarget)- .accessibilityIdentifier("work-detail-merge-button")- .accessibilityLabel("Merge this Work into another Work on the same site") } }- .sheet(- isPresented: Binding(- get: { activeMergeModel != nil },- set: { if !$0 { handleMergeDismissed() } }- )- ) {- if let mergeModel = activeMergeModel {- WorkMergeView(model: mergeModel)- }- }- .sheet(isPresented: $showingURLIdentityReview) {- if let reviewModel = model.reviewModel() {- URLIdentityReviewView(model: reviewModel)- }- }- .sheet(isPresented: $showingURLReteach) {- if let reteachModel = model.composedURLReteachModel() {- ComposedTeachingContainerView(model: reteachModel)- }- }- }-- /// Req 2.8 and Req 9.2 on this screen, mirroring Entry detail: say plainly- /// why the fields are off, and offer the route that turns them back on.- ///- /// Without it the reader types into a title field, finds Save greyed out,- /// and is told nothing — the silent dead end this milestone exists to- /// remove, reproduced on the Work half.- private var duplicateReviewSection: some View {- Section {- VStack(alignment: .leading, spacing: 6) {- Text("This work arrived \(model.rowCount) times and the copies differ.")- .font(.callout)- Text("Editing is off until you choose which one to keep — nothing has been lost.")- .font(.caption)- .foregroundStyle(.secondary)- if let onResolveDuplicate {- Button("Resolve copies") { onResolveDuplicate() }- .frame(- minWidth: AsterismLayout.minHitTarget,- minHeight: AsterismLayout.minHitTarget)- .accessibilityIdentifier("work-detail-resolve-duplicate-button")- }+ .navigationTitle("Edit Details")+ .navigationBarTitleDisplayMode(.inline)+ .accessibilityIdentifier("work-detail-edit-details")+ .toolbar {+ ToolbarItem(placement: .confirmationAction) {+ Button("Done") { onDone() }+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("work-detail-edit-details-done") }- .accessibilityIdentifier("work-detail-duplicate-notice")- }- }-- /// A committed merge deletes this source Work — leave the detail screen.- /// Otherwise just reload the unchanged Work.- private func handleMergeDismissed() {- let committed = activeMergeModel?.state == .committed- activeMergeModel = nil- if committed {- dismiss()- } else {- Task { await model.load() } } } }
diff --git a/Asterism/Asterism/Views/WorkMergeView.swift b/Asterism/Asterism/Views/WorkMergeView.swiftindex 2db8e12..9cbc962 100644--- a/Asterism/Asterism/Views/WorkMergeView.swift+++ b/Asterism/Asterism/Views/WorkMergeView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// Merge picker, preview, and confirmation. Reader sees exact consequences@@ -65,7 +66,7 @@ struct WorkMergeView: View { } label: { VStack(alignment: .leading, spacing: 4) { Text(work.displayTitle)- .font(AsterismTypography.serifTitle(15))+ .font(AsterismTypography.serifRowTitle) Text("\(work.entries.count) entries") .font(.caption) .foregroundStyle(.secondary)@@ -76,7 +77,7 @@ struct WorkMergeView: View { .accessibilityLabel("Merge into \(work.displayTitle), \(work.entries.count) entries") } } header: {- Text("Same-Site Works")+ ConstellationSectionHeader("Same-Site Works") } } }@@ -106,11 +107,11 @@ struct WorkMergeView: View { // Retained fields if !outcome.retainedFields.isEmpty {- Section("Retained") {+ Section(header: ConstellationSectionHeader("Retained", accent: .cyan)) { ForEach(outcome.retainedFields, id: \.rawValue) { field in HStack(spacing: 8) { Image(systemName: "checkmark.circle")- .foregroundStyle(.green)+ .foregroundStyle(AsterismColors.cyan) .accessibilityHidden(true) Text(fieldLabel(field)) }@@ -123,11 +124,11 @@ struct WorkMergeView: View { // Discarded fields if !outcome.discardedFields.isEmpty {- Section("Discarded") {+ Section(header: ConstellationSectionHeader("Discarded", accent: .violet)) { ForEach(outcome.discardedFields, id: \.rawValue) { field in HStack(spacing: 8) { Image(systemName: "archivebox")- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text(fieldLabel(field)) Text("Recorded in merged notes")@@ -143,7 +144,7 @@ struct WorkMergeView: View { // Audit block disclosure if let auditBlock = outcome.auditBlock {- Section("Audit Block Preview") {+ Section(header: ConstellationSectionHeader("Audit Block Preview", accent: .violet)) { DisclosureGroup { Text(auditBlock) .font(.footnote.monospaced())@@ -163,11 +164,11 @@ struct WorkMergeView: View { // Identity issues if !outcome.issues.isEmpty {- Section("Identity") {+ Section(header: ConstellationSectionHeader("Identity", accent: .violet)) { ForEach(Array(outcome.issues.enumerated()), id: \.offset) { _, issue in HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle")- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text(issueLabel(issue)) }@@ -180,20 +181,16 @@ struct WorkMergeView: View { // Confirm button Section {- Button {+ // Req 9.1: this screen's one gradient control. Everything else+ // on the confirmation is a disclosure or a toolbar item.+ Button("Confirm Merge") { Task { await model.confirmMerge() }- } label: {- HStack {- Spacer()- Text("Confirm Merge")- .font(.headline)- Spacer()- }- .frame(minHeight: AsterismLayout.minHitTarget)- .contentShape(Rectangle()) }+ .buttonStyle(.constellationPrimary) .accessibilityIdentifier("merge-confirm-button") .accessibilityLabel("Confirm merge into \(outcome.displayTitle)")+ .constellationListRow(+ insets: EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } } }@@ -205,7 +202,7 @@ struct WorkMergeView: View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle") .font(.largeTitle)- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text(message) .multilineTextAlignment(.center)
diff --git a/Asterism/Asterism/Views/WorksView.swift b/Asterism/Asterism/Views/WorksView.swiftindex 11f101a..a611abe 100644--- a/Asterism/Asterism/Views/WorksView.swift+++ b/Asterism/Asterism/Views/WorksView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// Works tab: shows non-empty works, empty works, and unattached entries group.@@ -29,21 +30,61 @@ struct WorksView: View { self.onResolveDuplicate = onResolveDuplicate } - private var nonEmptyWorks: [WorkSnapshot] {- snapshot.works.filter { !$0.entries.isEmpty }+ /// Req 4.1's query. The filter only removes rows, so the section split+ /// below (and with it empty works sinking) is unaffected by it.+ @State private var searchQuery = ""++ private var searchFilter: WorksSearchFilter {+ WorksSearchFilter(query: searchQuery) } - private var emptyWorks: [WorkSnapshot] {- snapshot.works.filter { $0.entries.isEmpty }+ private var displayedSnapshot: WorksSnapshot {+ searchFilter.apply(to: snapshot) } var body: some View {+ // Filtered and partitioned once per body evaluation — the filter runs+ // per keystroke, and the empty check plus both sections consume it.+ let displayedWorks = displayedSnapshot.works+ let nonEmptyWorks = displayedWorks.filter { !$0.entries.isEmpty }+ let emptyWorks = displayedWorks.filter { $0.entries.isEmpty }+ Group {+ if searchFilter.isActive && displayedWorks.isEmpty {+ // Req 4.3. The unattached group is hidden under a query (4.2),+ // so with no matching work there is genuinely nothing to list.+ ContentUnavailableView.search(text: searchQuery)+ .accessibilityIdentifier("works-search-empty")+ } else {+ worksList(nonEmptyWorks: nonEmptyWorks, emptyWorks: emptyWorks)+ }+ }+ // Requirement 8.1's fixed layer. On the screen's own root rather than+ // on the enclosing `NavigationStack`: a background applied outside the+ // stack renders behind the stack's own opaque backing and never+ // reaches the screen.+ .background { ConstellationBackground() }+ // On the outer container, so the field survives the empty branch.+ .searchable(text: $searchQuery, prompt: "Search works")+ .toolbar {+ ToolbarItem(placement: .primaryAction) {+ Button {+ onNewWork()+ } label: {+ Label("New Work", systemImage: "plus")+ }+ .accessibilityIdentifier("works-new-work-button")+ }+ }+ }++ private func worksList(nonEmptyWorks: [WorkSnapshot], emptyWorks: [WorkSnapshot]) -> some View { List { // Non-empty works section if !nonEmptyWorks.isEmpty { Section { ForEach(nonEmptyWorks, id: \.id) { work in workButton(work)+ .constellationListRow() } } }@@ -53,48 +94,67 @@ struct WorksView: View { Section { ForEach(emptyWorks, id: \.id) { work in workButton(work)+ .constellationListRow() } } } - // Unattached notes group- Section {- if snapshot.unattachedEntries.isEmpty {- Text("No unattached notes")- .foregroundStyle(.secondary)- .accessibilityIdentifier("unattached-empty")- } else {- ForEach(snapshot.unattachedEntries, id: \.id) { entry in- Button {- onSelectEntry(entry.id)- } label: {- UnattachedEntryRow(entry: entry)- .contentShape(Rectangle())- }- .buttonStyle(.plain)- .accessibilityIdentifier("unattached-entry-\(entry.id.uuidString)")- .accessibilityLabel("Open unattached entry \(entry.captureTitle)")+ // Unattached notes group. Hidden outright while a query is active+ // (Req 4.2, Q12): these entries have no work title to search, and+ // an empty group under a query reads as "your search cleared them".+ if !searchFilter.isActive {+ Section {+ unattachedGroup+ .constellationListRow()+ } header: {+ HStack(spacing: 6) {+ ConstellationSectionHeader("Unattached Notes", accent: .violet)+ .accessibilityIdentifier("unattached-section-header")+ Spacer()+ // §7/Req 9.4: the group carries a count pill, as the+ // work rows do. The header stays its own static-text+ // element beside it (Q42).+ Text("\(snapshot.unattachedEntries.count)")+ .constellationPill(.count)+ .accessibilityIdentifier("unattached-entry-count") } }- } header: {- Text("Unattached Notes")- .font(AsterismTypography.sectionHeader)- .textCase(.uppercase)- .accessibilityIdentifier("unattached-section-header") } } .listStyle(.plain)- .toolbar {- ToolbarItem(placement: .primaryAction) {- Button {- onNewWork()- } label: {- Label("New Work", systemImage: "plus")+ // Req 8.1: content scrolls over the tab stack's sky.+ .scrollContentBackground(.hidden)+ .accessibilityIdentifier("works-list")+ }++ /// §7's unattached-notes group: one dashed, lower-fill container around the+ /// rows rather than a dashed edge per row — the group is what the style+ /// guide marks.+ private var unattachedGroup: some View {+ VStack(alignment: .leading, spacing: 4) {+ if snapshot.unattachedEntries.isEmpty {+ Text("No unattached notes")+ .foregroundStyle(AsterismColors.secondaryText)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("unattached-empty")+ } else {+ ForEach(snapshot.unattachedEntries, id: \.id) { entry in+ Button {+ onSelectEntry(entry.id)+ } label: {+ UnattachedEntryRow(entry: entry)+ .frame(maxWidth: .infinity, alignment: .leading)+ .contentShape(Rectangle())+ }+ .buttonStyle(.plain)+ .accessibilityIdentifier("unattached-entry-\(entry.id.uuidString)")+ .accessibilityLabel("Open unattached entry \(entry.captureTitle)") }- .accessibilityIdentifier("works-new-work-button") } }- .accessibilityIdentifier("works-list")+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ .constellationDashedGroup() } /// The row and its Resolve pill are sibling buttons, exactly as Recent's@@ -122,20 +182,18 @@ struct WorksView: View { onResolveDuplicate(item.route.blockingWorkSet ?? item.key) } label: { Text(item.route == .merge ? "Merge" : "Resolve")- .font(.caption.bold())- .foregroundStyle(AsterismColors.amberDark)- .padding(.horizontal, 8)- .padding(.vertical, 10)- .frame(minHeight: AsterismLayout.minHitTarget)- .background(AsterismColors.amberDark.opacity(0.15))- .clipShape(Capsule())+ .constellationPill(.attention) } .buttonStyle(.plain)- .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("work-resolve-duplicate-pill") .accessibilityLabel("Resolve duplicate copies of \(work.displayTitle)") } }+ .padding(.vertical, 10)+ .padding(.horizontal, 12)+ // A Work awaiting a decision wears the amber border its Entries do+ // (Decision 1); every other work row is the plain card.+ .constellationCard(borderColor: item == nil ? nil : AsterismColors.attentionBorder) } } @@ -145,32 +203,33 @@ struct WorkRow: View { var body: some View { VStack(alignment: .leading, spacing: 4) {+ // Req 11.3: single-line work names truncate, never wrap. Text(work.displayTitle)- .font(AsterismTypography.serifTitle(15))+ .font(AsterismTypography.serifRowTitle) .lineLimit(1)+ .truncationMode(.tail)+ .foregroundStyle(AsterismColors.primaryText) .accessibilityIdentifier("work-title") HStack(spacing: 6) {- Text(work.siteHostname)- .font(.caption)- .foregroundStyle(.secondary)+ // Req 9.2: the glyph stands in for the hostname text.+ SiteGlyph(hostname: work.siteHostname, size: 18) if work.type != .other {+ // §7's type tag: violet tint and border. Text(work.type.rawValue)- .font(.caption2.weight(.semibold))- .foregroundStyle(AsterismColors.violetDark)+ .constellationPill(.typeTag) .accessibilityIdentifier("work-type-tag") } Spacer() + // §7's count pill: cyan text on a cyan .12 fill. Text("\(work.entries.count)")- .font(.caption2.weight(.bold))- .foregroundStyle(AsterismColors.cyanDark)+ .constellationPill(.count) .accessibilityIdentifier("work-entry-count") } }- .padding(.vertical, 4) .frame(minHeight: AsterismLayout.minHitTarget) } }@@ -182,24 +241,26 @@ struct UnattachedEntryRow: View { var body: some View { VStack(alignment: .leading, spacing: 4) { Text(entry.captureTitle)- .font(AsterismTypography.serifTitle(15))+ .font(AsterismTypography.serifRowTitle) .lineLimit(1)+ .truncationMode(.tail)+ .foregroundStyle(AsterismColors.primaryText) .accessibilityIdentifier("entry-title") HStack(spacing: 6) {- Text(entry.hostname)- .font(.caption)- .foregroundStyle(.secondary)+ // §7's dim ✎ glyph marks the group; an unattached entry has no+ // taught identity to colour a site glyph with.+ SiteGlyph(hostname: entry.hostname, kind: .articles, size: 18) .accessibilityIdentifier("entry-hostname") Spacer() if let rating = entry.rating { Text(rating == .up ? "▲" : "▼") .font(.caption)- .foregroundStyle(rating == .up ? AsterismColors.cyanDark : AsterismColors.violetDark)+ .foregroundStyle(rating == .up ? AsterismColors.cyan : AsterismColors.violet) } Text(entry.lastSharedAt, style: .time) .font(.caption2)- .foregroundStyle(.secondary)+ .foregroundStyle(AsterismColors.secondaryText) } } .padding(.vertical, 4)
diff --git a/Asterism/AsterismShareExtension/CaptureView.swift b/Asterism/AsterismShareExtension/CaptureView.swiftindex 9bad3d3..0672d70 100644--- a/Asterism/AsterismShareExtension/CaptureView.swift+++ b/Asterism/AsterismShareExtension/CaptureView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI /// Capture sheet SwiftUI view: loading, invalid URL, first-site amber state,@@ -9,8 +10,6 @@ import SwiftUI struct CaptureView: View { @ObservedObject var viewModel: ObservableCaptureViewModel @FocusState private var noteFieldFocused: Bool- @Environment(\.accessibilityReduceTransparency) private var reduceTransparency- @Environment(\.colorScheme) private var colorScheme let onSaveCompleted: () -> Void let onCancel: () -> Void @@ -39,7 +38,7 @@ struct CaptureView: View { .disabled(viewModel.isSaving) .accessibilityLabel("Cancel capture") .accessibilityIdentifier("capture.cancel")- .frame(minWidth: 44, minHeight: 44)+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget) } } }@@ -68,16 +67,6 @@ struct CaptureView: View { } } - // MARK: - Surface background (Reduce Transparency aware)-- private var fieldBackground: some ShapeStyle {- if reduceTransparency {- return AnyShapeStyle(colorScheme == .dark ? Color(white: 0.18) : Color(white: 0.92))- } else {- return AnyShapeStyle(.fill.quaternary)- }- }- // MARK: - Loading private var loadingContent: some View {@@ -158,7 +147,7 @@ struct CaptureView: View { VStack(spacing: 12) { Image(systemName: "checkmark.circle.fill") .font(.largeTitle)- .foregroundStyle(.green)+ .foregroundStyle(AsterismColors.cyan) .accessibilityHidden(true) Text("Saved") .font(.headline)@@ -180,23 +169,32 @@ struct CaptureView: View { manualTitleInput } else if let title = preparation.resolvedTitle { Text(title)- .font(.headline)+ .font(AsterismTypography.serifHeading) .accessibilityLabel("Page title") .accessibilityIdentifier("capture.pageTitle") } if let host = Self.hostname(from: preparation.rawURL) {- Text(host)- .font(.caption)- .foregroundStyle(.secondary)- .accessibilityLabel("Site: \(host)")- .accessibilityIdentifier("capture.hostname")+ HStack(spacing: 6) {+ // Requirement 9.2: a hostname shown as plain text takes the+ // glyph instead.+ SiteGlyph(+ hostname: host,+ kind: preparation.siteStatus == .newSite ? .unknown : .site,+ size: 18+ )+ Text(host)+ .font(.caption)+ .foregroundStyle(AsterismColors.secondaryText)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel("Site: \(host)")+ .accessibilityIdentifier("capture.hostname") } } .frame(maxWidth: .infinity, alignment: .leading) .padding()- .background(fieldBackground)- .clipShape(RoundedRectangle(cornerRadius: 12))+ .constellationField() .accessibilityIdentifier("capture.metadata") } @@ -224,7 +222,7 @@ struct CaptureView: View { HStack(spacing: 6) { Image(systemName: "bookmark.fill") .font(.caption)- .foregroundStyle(.blue)+ .foregroundStyle(AsterismColors.cyan) .accessibilityHidden(true) Text("Chapter: \(chapter)") .font(.subheadline)@@ -264,7 +262,7 @@ struct CaptureView: View { HStack(spacing: 6) { Image(systemName: "book.fill") .font(.caption)- .foregroundStyle(.purple)+ .foregroundStyle(AsterismColors.violet) .accessibilityHidden(true) Text(workDescription) .font(.subheadline)@@ -277,11 +275,11 @@ struct CaptureView: View { HStack(spacing: 6) { Image(systemName: "exclamationmark.circle") .font(.caption)- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text("Title does not match pattern") .font(.subheadline)- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) } .accessibilityElement(children: .combine) .accessibilityLabel("Title does not match pattern")@@ -293,11 +291,11 @@ struct CaptureView: View { HStack(spacing: 6) { Image(systemName: "flag.fill") .font(.caption)- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text("Actionable — needs review after save") .font(.caption)- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) } .accessibilityElement(children: .combine) .accessibilityLabel("Actionable. Needs review after save.")@@ -306,8 +304,7 @@ struct CaptureView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding()- .background(fieldBackground)- .clipShape(RoundedRectangle(cornerRadius: 12))+ .constellationField() .accessibilityIdentifier("capture.projectedMetadata") } @@ -315,13 +312,13 @@ struct CaptureView: View { private var newSiteBanner: some View { HStack(spacing: 6) {- Image(systemName: "star.fill")+ Image(systemName: "sparkle") .font(.caption)- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text("New site — title saved, teach later in app") .font(.caption)- .foregroundStyle(.orange)+ .foregroundStyle(AsterismColors.amberText) } .accessibilityElement(children: .combine) .accessibilityLabel("New site. Title will be saved. Teach later in app.")@@ -330,18 +327,15 @@ struct CaptureView: View { private func reviewBanner(message: String) -> some View { HStack(spacing: 8) { Image(systemName: "arrow.triangle.2.circlepath")- .foregroundStyle(.blue)+ .foregroundStyle(AsterismColors.cyan) .accessibilityHidden(true) Text(message) .font(.subheadline)- .foregroundStyle(.primary)+ .foregroundStyle(AsterismColors.primaryText) } .padding() .frame(maxWidth: .infinity, alignment: .leading)- .background(reduceTransparency- ? AnyShapeStyle(colorScheme == .dark ? Color(white: 0.15) : Color(white: 0.95))- : AnyShapeStyle(Color.blue.opacity(0.1)))- .clipShape(RoundedRectangle(cornerRadius: 12))+ .constellationField() .accessibilityElement(children: .combine) .accessibilityLabel(message) .accessibilityIdentifier("capture.reviewBanner")@@ -349,29 +343,26 @@ struct CaptureView: View { private var manualTitleInput: some View { TextField("Title (required)", text: $viewModel.manualTitle)- .font(.headline)+ .font(AsterismTypography.serifHeading) .textFieldStyle(.plain) .accessibilityLabel("Page title, required") .accessibilityHint("Enter a title for this capture") .accessibilityIdentifier("capture.manualTitle")- .frame(minHeight: 44)+ .frame(minHeight: AsterismLayout.minHitTarget) } // MARK: - Note private var noteSection: some View { VStack(alignment: .leading, spacing: 4) {- Text("Note")- .font(.caption)- .foregroundStyle(.secondary)+ ConstellationSectionHeader("Note") TextEditor(text: $viewModel.note) .focused($noteFieldFocused) .font(.body) .frame(minHeight: 88) .scrollContentBackground(.hidden) .padding(8)- .background(fieldBackground)- .clipShape(RoundedRectangle(cornerRadius: 12))+ .constellationField() .accessibilityLabel("Note") .accessibilityHint("Optional note for this capture") .accessibilityIdentifier("capture.note")@@ -395,18 +386,15 @@ struct CaptureView: View { private func ratingToggle(rating: Rating, symbol: String, label: String) -> some View { let isSelected = viewModel.currentRating == rating- let accentColor: Color = rating == .up ? .cyan : .purple return Button { viewModel.toggleRating(rating) } label: { Image(systemName: symbol) .font(.title3)- .foregroundStyle(isSelected ? accentColor : .secondary)- .frame(width: 48, height: 44)- .background(isSelected ? accentColor.opacity(0.15) : Color.clear)- .clipShape(Capsule())- .overlay(Capsule().stroke(isSelected ? accentColor.opacity(0.55) : Color.clear, lineWidth: 1)) }+ .buttonStyle(+ .constellationRatingToggle(rating == .up ? .up : .down, isActive: isSelected)+ ) .disabled(viewModel.isSaving) .accessibilityLabel(label) .accessibilityIdentifier("capture.rating.\(rating.rawValue)")@@ -420,10 +408,8 @@ struct CaptureView: View { viewModel.save() } label: { Text("Save")- .font(.headline)- .frame(maxWidth: .infinity, minHeight: 44) }- .buttonStyle(.borderedProminent)+ .buttonStyle(.constellationPrimary) .disabled(!viewModel.canSave || viewModel.isSaving) .accessibilityLabel("Save capture") .accessibilityIdentifier("capture.save")@@ -434,10 +420,8 @@ struct CaptureView: View { viewModel.save() } label: { Text("Try Again")- .font(.headline)- .frame(maxWidth: .infinity, minHeight: 44) }- .buttonStyle(.borderedProminent)+ .buttonStyle(.constellationPrimary) .disabled(!viewModel.canSave || viewModel.isSaving) .accessibilityLabel("Try saving again") .accessibilityIdentifier("capture.retry")@@ -446,18 +430,17 @@ struct CaptureView: View { private func errorBanner(message: String) -> some View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill")- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text(message) .font(.subheadline)- .foregroundStyle(.primary)+ .foregroundStyle(AsterismColors.primaryText) } .padding() .frame(maxWidth: .infinity, alignment: .leading)- .background(reduceTransparency- ? AnyShapeStyle(colorScheme == .dark ? Color(white: 0.15) : Color(white: 0.95))- : AnyShapeStyle(Color.red.opacity(0.1)))- .clipShape(RoundedRectangle(cornerRadius: 12))+ // Decision 1: a failure the reader has to act on is an+ // actionable-attention surface.+ .constellationAttentionSurface(cornerRadius: AsterismLayout.fieldRadius) .accessibilityElement(children: .combine) .accessibilityLabel("Error: \(message)") .accessibilityIdentifier("capture.error")
diff --git a/Asterism/AsterismShareExtension/ReShareCaptureView.swift b/Asterism/AsterismShareExtension/ReShareCaptureView.swiftindex 6279402..81c864b 100644--- a/Asterism/AsterismShareExtension/ReShareCaptureView.swift+++ b/Asterism/AsterismShareExtension/ReShareCaptureView.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI // MARK: - Re-share capture view (Design §9.4, task 36)@@ -13,8 +14,6 @@ import SwiftUI struct ReShareCaptureView: View { @ObservedObject var viewModel: ObservableReShareViewModel @FocusState private var noteFieldFocused: Bool- @Environment(\.accessibilityReduceTransparency) private var reduceTransparency- @Environment(\.colorScheme) private var colorScheme let onCompleted: () -> Void let onCancel: () -> Void @@ -34,8 +33,8 @@ struct ReShareCaptureView: View { .disabled(viewModel.isSaving) .accessibilityLabel("Cancel capture") .accessibilityIdentifier("reshare.cancel")- .frame(minWidth: ReShareLayoutConstants.minimumHitTarget,- minHeight: ReShareLayoutConstants.minimumHitTarget)+ .frame(minWidth: AsterismLayout.minHitTarget,+ minHeight: AsterismLayout.minHitTarget) } } }@@ -62,16 +61,6 @@ struct ReShareCaptureView: View { } } - // MARK: - Surface background-- private var fieldBackground: some ShapeStyle {- if reduceTransparency {- return AnyShapeStyle(colorScheme == .dark ? Color(white: 0.18) : Color(white: 0.92))- } else {- return AnyShapeStyle(.fill.quaternary)- }- }- // MARK: - Loading private var loadingContent: some View {@@ -130,18 +119,15 @@ struct ReShareCaptureView: View { let bannerText = ReShareBannerFormatter.format(firstCapturedAt: firstCapturedAt) return HStack(spacing: 8) { Image(systemName: "pencil.circle.fill")- .foregroundStyle(.blue)+ .foregroundStyle(AsterismColors.cyan) .accessibilityHidden(true) Text(bannerText) .font(.subheadline)- .foregroundStyle(.primary)+ .foregroundStyle(AsterismColors.primaryText) } .padding() .frame(maxWidth: .infinity, alignment: .leading)- .background(reduceTransparency- ? AnyShapeStyle(colorScheme == .dark ? Color(white: 0.15) : Color(white: 0.95))- : AnyShapeStyle(Color.blue.opacity(0.1)))- .clipShape(RoundedRectangle(cornerRadius: 12))+ .constellationField() .accessibilityElement(children: .combine) .accessibilityLabel(bannerText) .accessibilityIdentifier("reshare.editBanner")@@ -152,7 +138,7 @@ struct ReShareCaptureView: View { private var newCaptureContent: some View { VStack(spacing: 12) { Text("New capture")- .font(.headline)+ .font(AsterismTypography.serifHeading) Text("This page hasn't been captured before.") .font(.subheadline) .foregroundStyle(.secondary)@@ -181,7 +167,7 @@ struct ReShareCaptureView: View { VStack(spacing: 12) { Image(systemName: "checkmark.circle.fill") .font(.largeTitle)- .foregroundStyle(.green)+ .foregroundStyle(AsterismColors.cyan) .accessibilityHidden(true) Text("Updated") .font(.headline)@@ -194,17 +180,14 @@ struct ReShareCaptureView: View { private var noteSection: some View { VStack(alignment: .leading, spacing: 4) {- Text("Note")- .font(.caption)- .foregroundStyle(.secondary)+ ConstellationSectionHeader("Note") TextEditor(text: $viewModel.draftNote) .focused($noteFieldFocused) .font(.body) .frame(minHeight: 88) .scrollContentBackground(.hidden) .padding(8)- .background(fieldBackground)- .clipShape(RoundedRectangle(cornerRadius: 12))+ .constellationField() .accessibilityLabel("Note") .accessibilityHint("Edit your note for this entry") .accessibilityIdentifier("reshare.note")@@ -226,18 +209,15 @@ struct ReShareCaptureView: View { private func ratingToggle(rating: Rating, symbol: String, label: String) -> some View { let isSelected = viewModel.draftRating == rating- let accentColor: Color = rating == .up ? .cyan : .purple return Button { viewModel.toggleRating(rating) } label: { Image(systemName: symbol) .font(.title3)- .foregroundStyle(isSelected ? accentColor : .secondary)- .frame(width: 48, height: ReShareLayoutConstants.minimumHitTarget)- .background(isSelected ? accentColor.opacity(0.15) : Color.clear)- .clipShape(Capsule())- .overlay(Capsule().stroke(isSelected ? accentColor.opacity(0.55) : Color.clear, lineWidth: 1)) }+ .buttonStyle(+ .constellationRatingToggle(rating == .up ? .up : .down, isActive: isSelected)+ ) .disabled(viewModel.isSaving) .accessibilityLabel(label) .accessibilityIdentifier("reshare.rating.\(rating.rawValue)")@@ -249,11 +229,8 @@ struct ReShareCaptureView: View { viewModel.submitUpdate() } label: { Text(ReShareActionLabels.primaryAction(for: viewModel.state))- .font(.headline)- .frame(maxWidth: .infinity,- minHeight: ReShareLayoutConstants.minimumHitTarget) }- .buttonStyle(.borderedProminent)+ .buttonStyle(.constellationPrimary) .disabled(viewModel.isSaving) .accessibilityLabel(ReShareActionLabels.primaryActionAccessibilityLabel(for: viewModel.state)) .accessibilityIdentifier("reshare.update")@@ -262,18 +239,17 @@ struct ReShareCaptureView: View { private func errorBanner(message: String) -> some View { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill")- .foregroundStyle(.red)+ .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true) Text(message) .font(.subheadline)- .foregroundStyle(.primary)+ .foregroundStyle(AsterismColors.primaryText) } .padding() .frame(maxWidth: .infinity, alignment: .leading)- .background(reduceTransparency- ? AnyShapeStyle(colorScheme == .dark ? Color(white: 0.15) : Color(white: 0.95))- : AnyShapeStyle(Color.red.opacity(0.1)))- .clipShape(RoundedRectangle(cornerRadius: 12))+ // Decision 1: a failure the reader has to act on is an+ // actionable-attention surface.+ .constellationAttentionSurface(cornerRadius: AsterismLayout.fieldRadius) .accessibilityElement(children: .combine) .accessibilityLabel("Error: \(message)") .accessibilityIdentifier("reshare.error")@@ -401,8 +377,8 @@ struct UnavailableSetupView: View { Button("Done") { onDismiss() } .accessibilityLabel("Dismiss") .accessibilityIdentifier("unavailable.dismiss")- .frame(minWidth: ReShareLayoutConstants.minimumHitTarget,- minHeight: ReShareLayoutConstants.minimumHitTarget)+ .frame(minWidth: AsterismLayout.minHitTarget,+ minHeight: AsterismLayout.minHitTarget) } } }
diff --git a/Asterism/AsterismShareExtension/ShareViewController.swift b/Asterism/AsterismShareExtension/ShareViewController.swiftindex 5a08903..6766662 100644--- a/Asterism/AsterismShareExtension/ShareViewController.swift+++ b/Asterism/AsterismShareExtension/ShareViewController.swift@@ -1,4 +1,5 @@ import AsterismCore+import ConstellationKit import SwiftUI import UIKit @@ -16,7 +17,10 @@ final class ShareViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad()- view.backgroundColor = .systemBackground+ // Clear, not `.systemBackground`: the SwiftUI root supplies the §4+ // sheet glass, and an opaque host view underneath would flatten it+ // (requirement 10.1).+ view.backgroundColor = .clear accessibilityViewIsModal = true host(AnyView(LoadingCaptureView()))@@ -100,8 +104,9 @@ final class ShareViewController: UIViewController { hostingController?.view.removeFromSuperview() hostingController?.removeFromParent() - let hosting = UIHostingController(rootView: view)+ let hosting = UIHostingController(rootView: AnyView(CaptureSheetChrome { view })) addChild(hosting)+ hosting.view.backgroundColor = .clear hosting.view.translatesAutoresizingMaskIntoConstraints = false self.view.addSubview(hosting.view) NSLayoutConstraint.activate([@@ -135,6 +140,50 @@ final class ShareViewController: UIViewController { } } +// MARK: - Sheet chrome++/// The §4 sheet treatment for every hosted state (requirement 10.1): sheet+/// glass plus the specular top edge.+///+/// Deliberately **not** the sky layer — requirement 10.3 keeps the background+/// layer out of the extension, and stacking sky under sheet glass would breach+/// the style guide's two-layer maximum anyway. The corner shape is the system+/// sheet's; this only fills behind the content, so nothing here can fail a+/// capture.+private struct CaptureSheetChrome<Content: View>: View {+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency++ private let content: Content++ init(@ViewBuilder content: () -> Content) {+ self.content = content()+ }++ var body: some View {+ content+ .background {+ Group {+ if reduceTransparency {+ Rectangle().fill(AsterismColors.opaqueCard)+ } else {+ ZStack {+ Rectangle().fill(.regularMaterial)+ Rectangle().fill(AsterismColors.sheetFill)+ }+ }+ }+ .ignoresSafeArea()+ }+ .overlay(alignment: .top) {+ Rectangle()+ .fill(AsterismColors.specularEdge)+ .frame(height: 1)+ .ignoresSafeArea(edges: .horizontal)+ .allowsHitTesting(false)+ }+ }+}+ // MARK: - Simple hosted states private struct LoadingCaptureView: View {
diff --git a/Asterism/AsterismTests/ConflictRecentEntryDetailPresentationTests.swift b/Asterism/AsterismTests/ConflictRecentEntryDetailPresentationTests.swiftindex 3ed0817..10a820c 100644--- a/Asterism/AsterismTests/ConflictRecentEntryDetailPresentationTests.swift+++ b/Asterism/AsterismTests/ConflictRecentEntryDetailPresentationTests.swift@@ -154,13 +154,14 @@ struct ConflictRecentEntryDetailPresentationTests { // and does not use a cached conflict flag. let mock = MockLibraryProvider() let work = TestFixtures.makeWork(id: UUID(), displayTitle: "Test Work")- mock.workResult = .success(work)+ mock.workDetailResult = .success(TestFixtures.makeWorkDetail(work: work)) let model = WorkDetailModel(workID: work.id, library: mock, onMutation: {}) await model.load() - // Loading the work invokes work() — that's the live read.- #expect(mock.workCallCount == 1)+ // Loading the work invokes workDetail() — that's the live read (M5+ // swapped work(id:) for the §6 read; it is still one live call).+ #expect(mock.workDetailCallCount == 1) } // MARK: - Accessibility
diff --git a/Asterism/AsterismTests/DuplicateSurfaceTests.swift b/Asterism/AsterismTests/DuplicateSurfaceTests.swiftindex 59d5963..b6c4dfb 100644--- a/Asterism/AsterismTests/DuplicateSurfaceTests.swift+++ b/Asterism/AsterismTests/DuplicateSurfaceTests.swift@@ -421,20 +421,20 @@ struct DuplicateSurfaceTests { @MainActor func aTornWorkIsReadOnly() async { let library = MockLibraryProvider() let work = TestFixtures.makeWork(displayTitle: "Serial")- library.workResult = .success(- WorkSnapshot(- id: work.id, displayTitle: work.displayTitle, lastParsedTitle: "Serial",- siteHostname: work.siteHostname, urlIdentity: nil, workURLString: nil,- genericNotes: "", type: .other, genreTags: [], titleProvenance: .parsed,- createdAt: work.createdAt, modifiedAt: work.modifiedAt, entries: [],- groupState: .torn(variants: [- AuthoredVariant(- content: WorkAuthoredContent(genericNotes: "this device"),- firstCapturedAt: TestFixtures.fixedDate),- AuthoredVariant(- content: WorkAuthoredContent(genericNotes: "the other one"),- firstCapturedAt: TestFixtures.laterDate),- ])))+ let torn = WorkSnapshot(+ id: work.id, displayTitle: work.displayTitle, lastParsedTitle: "Serial",+ siteHostname: work.siteHostname, urlIdentity: nil, workURLString: nil,+ genericNotes: "", type: .other, genreTags: [], titleProvenance: .parsed,+ createdAt: work.createdAt, modifiedAt: work.modifiedAt, entries: [],+ groupState: .torn(variants: [+ AuthoredVariant(+ content: WorkAuthoredContent(genericNotes: "this device"),+ firstCapturedAt: TestFixtures.fixedDate),+ AuthoredVariant(+ content: WorkAuthoredContent(genericNotes: "the other one"),+ firstCapturedAt: TestFixtures.laterDate),+ ]))+ library.workDetailResult = .success(TestFixtures.makeWorkDetail(work: torn)) let model = WorkDetailModel(workID: work.id, library: library, onMutation: {}) await model.load()
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftindex 1952d72..6c58665 100644--- a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -137,6 +137,74 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable { return try workDestinationsResult.get() } + // MARK: - M5 reads and work deletion++ var sitesCallCount = 0+ var workDetailCallCount = 0+ var entryExportInputCallCount = 0+ var workExportInputCallCount = 0+ var projectWorkDeletionCallCount = 0+ var commitWorkDeletionCallCount = 0++ var sitesResult: Result<[SiteSnapshot], Error> = .success([])+ var workDetailResult: Result<WorkDetailPresentation, Error> = .failure(MockError.notConfigured)+ var entryExportInputResult: Result<EntryExportInput, Error> = .failure(MockError.notConfigured)+ var workExportInputResult: Result<WorkExportInput, Error> = .failure(MockError.notConfigured)+ var projectWorkDeletionResult: Result<WorkDeletionContract, Error> = .failure(+ MockError.notConfigured)+ var commitWorkDeletionResult: Result<WorkDeletionCommitOutcome, Error> = .success(.committed)++ var lastExportLocale: Locale?+ /// Holds an export-input read open, so a second submission can be observed+ /// arriving while the first is still in flight.+ var exportInputDelay: Duration?+ var lastWorkDeletionDisposition: WorkDeletionDisposition?+ /// Doubly optional for `deleteEntry`'s reason: the outer level says whether+ /// the commit was called, the inner one carries what it was passed — and+ /// `nil` there is the "no disclosure was made" a torn group refuses.+ var lastWorkDeletionDisclosedVariants: Set<VariantID>??++ func sites() async throws -> [SiteSnapshot] {+ sitesCallCount += 1+ callLog.append("sites")+ return try sitesResult.get()+ }++ func workDetail(id: UUID) async throws -> WorkDetailPresentation {+ workDetailCallCount += 1+ callLog.append("workDetail")+ return try workDetailResult.get()+ }++ func entryExportInput(entryID: UUID, locale: Locale) async throws -> EntryExportInput {+ entryExportInputCallCount += 1+ lastExportLocale = locale+ if let exportInputDelay { try? await Task.sleep(for: exportInputDelay) }+ return try entryExportInputResult.get()+ }++ func workExportInput(workID: UUID, locale: Locale) async throws -> WorkExportInput {+ workExportInputCallCount += 1+ lastExportLocale = locale+ if let exportInputDelay { try? await Task.sleep(for: exportInputDelay) }+ return try workExportInputResult.get()+ }++ func projectWorkDeletion(workID: UUID) async throws -> WorkDeletionContract {+ projectWorkDeletionCallCount += 1+ return try projectWorkDeletionResult.get()+ }++ func commitWorkDeletion(+ _ contract: WorkDeletionContract, disposition: WorkDeletionDisposition,+ disclosedVariants: Set<VariantID>?+ ) async throws -> WorkDeletionCommitOutcome {+ commitWorkDeletionCallCount += 1+ lastWorkDeletionDisposition = disposition+ lastWorkDeletionDisclosedVariants = .some(disclosedVariants)+ return try commitWorkDeletionResult.get()+ }+ // MARK: - Teaching contract stubs var entryTeachingDetailResult: Result<EntryTeachingDetail, Error> = .failure(MockError.notConfigured)
diff --git a/Asterism/AsterismTests/Helpers/TestFixtures.swift b/Asterism/AsterismTests/Helpers/TestFixtures.swiftindex 205ea9f..18b0e0b 100644--- a/Asterism/AsterismTests/Helpers/TestFixtures.swift+++ b/Asterism/AsterismTests/Helpers/TestFixtures.swift@@ -39,6 +39,66 @@ enum TestFixtures { ) } + /// A Recent row carrying exactly the fields search reads (Req 3.1, 3.3).+ /// Everything else is the settled, unactionable shape — the rows the search+ /// tests care about are ordinary ones.+ static func makeRecentRow(+ id: UUID = UUID(),+ captureTitle: String = "Test Entry",+ hostname: String = "example.com",+ workDisplayTitle: String? = nil,+ chapterTitle: String? = nil,+ note: String = "",+ rating: Rating? = nil,+ isActionable: Bool = false,+ lastSharedAt: Date = fixedDate+ ) -> RecentPresentationRow {+ RecentPresentationRow(+ id: id,+ entry: makeEntry(+ id: id, captureTitle: captureTitle, hostname: hostname, note: note,+ rating: rating, lastSharedAt: lastSharedAt),+ captureTitle: captureTitle,+ hostname: hostname,+ workDisplayTitle: workDisplayTitle,+ chapterTitle: chapterTitle,+ unresolvedCandidateTitle: nil,+ siteMode: .taught,+ isActionable: isActionable,+ actionType: isActionable ? .reteach : .none,+ note: note,+ rating: rating,+ lastSharedAt: lastSharedAt+ )+ }++ /// The §6 Work-detail read, defaulting to the shape a one-entry work+ /// produces so a test only states the part it is about.+ static func makeWorkDetail(+ work: WorkSnapshot,+ pulse: RatingPulse? = nil,+ lastNotedURLString: String? = nil,+ chapterRows: [WorkChapterRow]? = nil+ ) -> WorkDetailPresentation {+ let rows = chapterRows ?? work.entries.map { entry in+ WorkChapterRow(+ id: entry.id,+ displayTitle: entry.chapterTitle ?? entry.captureTitle,+ note: entry.note,+ rating: entry.rating,+ lastSharedAt: entry.lastSharedAt)+ }+ return WorkDetailPresentation(+ work: work,+ pulse: pulse ?? RatingPulse(+ notes: work.entries.count,+ up: work.entries.filter { $0.rating == .up }.count,+ down: work.entries.filter { $0.rating == .down }.count),+ lastNotedURLString: lastNotedURLString+ ?? work.entries.max(by: { $0.lastSharedAt < $1.lastSharedAt })?.rawURLString,+ chapterRows: rows)+ }+ static func makeWork( id: UUID = UUID(), displayTitle: String = "Test Work",
diff --git a/Asterism/AsterismTests/MarkdownExportModelTests.swift b/Asterism/AsterismTests/MarkdownExportModelTests.swiftnew file mode 100644index 0000000..1c8ba5d--- /dev/null+++ b/Asterism/AsterismTests/MarkdownExportModelTests.swift@@ -0,0 +1,323 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++// The markdown export presentation layer (Reqs 1.5, 2.4). Mirrors+// `SettingsBackupModelTests`: the state machine, the staged file, the cleanup on+// dismissal, the stale-file scavenging, and the privacy-safe messages.++@Suite("Markdown export view model")+struct MarkdownExportModelTests {++ // MARK: - Helpers++ private func makeStagingDirectory() -> URL {+ let url = FileManager.default.temporaryDirectory+ .appending(path: "markdown-export-tests-\(UUID().uuidString)")+ try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+ return url+ }++ private func entryInput(+ heading: String = "Chapter 14", note: String = "a good one"+ ) -> EntryExportInput {+ EntryExportInput(+ headingText: heading,+ rawURLString: "https://example.com/c14",+ workTitle: "The Perfect Run",+ workTypeLabel: "novel",+ dateText: "12 May 2026",+ rating: .up,+ note: note)+ }++ private func workInput(title: String = "The Perfect Run") -> WorkExportInput {+ WorkExportInput(+ titleText: title,+ siteName: "Example",+ workURLString: "https://example.com/work",+ genericNotes: "",+ blocks: [entryInput()])+ }++ @MainActor private func makeSUT(+ subject: MarkdownExportModel.Subject,+ stagingDirectory: URL,+ configure: (MockLibraryProvider) -> Void+ ) -> (MarkdownExportModel, MockLibraryProvider) {+ let mock = MockLibraryProvider()+ configure(mock)+ let model = MarkdownExportModel(+ subject: subject,+ library: mock,+ stagingDirectory: stagingDirectory,+ locale: Locale(identifier: "en_AU"))+ return (model, mock)+ }++ // MARK: - Initial state++ @Test("Starts idle with nothing staged")+ @MainActor func initialState() {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let (model, _) = makeSUT(subject: .entry(UUID()), stagingDirectory: dir) { _ in }+ #expect(model.state == .idle)+ #expect(model.exportedFileURL == nil)+ #expect(model.errorMessage == nil)+ }++ // MARK: - Happy path (1.5)++ @Test("Exporting an entry stages a markdown file and moves to sharing")+ @MainActor func entryExportStages() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let (model, mock) = makeSUT(subject: .entry(UUID()), stagingDirectory: dir) { mock in+ mock.entryExportInputResult = .success(entryInput())+ }++ await model.startExport()++ #expect(model.state == .sharing)+ #expect(mock.entryExportInputCallCount == 1)+ guard let url = model.exportedFileURL else {+ Issue.record("Expected a staged file URL")+ return+ }+ #expect(url.pathExtension == "md")+ #expect(url.deletingLastPathComponent().standardizedFileURL == dir.standardizedFileURL)+ #expect(FileManager.default.fileExists(atPath: url.path))+ let contents = (try? String(contentsOf: url, encoding: .utf8)) ?? ""+ #expect(contents == MarkdownExport.renderEntry(entryInput()))+ }++ @Test("The staged filename is the renderer's, from the sanitised title")+ @MainActor func filenameComesFromTheRenderer() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let input = entryInput(heading: "Ch 14: Bees / Wasps")+ let (model, _) = makeSUT(subject: .entry(UUID()), stagingDirectory: dir) { mock in+ mock.entryExportInputResult = .success(input)+ }++ await model.startExport()++ #expect(model.exportedFileURL?.lastPathComponent == MarkdownExport.filename(for: input))+ }++ @Test("Exporting a work stages the per-work document")+ @MainActor func workExportStages() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let (model, mock) = makeSUT(subject: .work(UUID()), stagingDirectory: dir) { mock in+ mock.workExportInputResult = .success(workInput())+ }++ await model.startExport()++ #expect(model.state == .sharing)+ #expect(mock.workExportInputCallCount == 1)+ #expect(mock.entryExportInputCallCount == 0)+ let contents = model.exportedFileURL.flatMap { try? String(contentsOf: $0, encoding: .utf8) }+ #expect(contents == MarkdownExport.renderWork(workInput()))+ }++ /// The locale dependency lives in the input layer (Q17), so the model has to+ /// hand its locale to the read rather than formatting anything itself.+ @Test("The model's locale reaches the input read")+ @MainActor func localeReachesTheRead() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let (model, mock) = makeSUT(subject: .entry(UUID()), stagingDirectory: dir) { mock in+ mock.entryExportInputResult = .success(entryInput())+ }++ await model.startExport()++ #expect(mock.lastExportLocale == Locale(identifier: "en_AU"))+ }++ @Test("Suppresses a duplicate export while one is in flight")+ @MainActor func duplicateSubmissionSuppressed() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let (model, mock) = makeSUT(subject: .entry(UUID()), stagingDirectory: dir) { mock in+ mock.entryExportInputResult = .success(entryInput())+ mock.exportInputDelay = .milliseconds(100)+ }++ async let _ = model.startExport()+ await model.startExport()++ #expect(mock.entryExportInputCallCount == 1)+ }++ // MARK: - Cleanup++ @Test("Completing the share removes the staged file and returns to idle")+ @MainActor func shareCompletionCleansUp() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let (model, _) = makeSUT(subject: .entry(UUID()), stagingDirectory: dir) { mock in+ mock.entryExportInputResult = .success(entryInput())+ }+ await model.startExport()+ let staged = model.exportedFileURL++ model.handleShareCompletion()++ #expect(model.state == .idle)+ #expect(model.exportedFileURL == nil)+ #expect(!FileManager.default.fileExists(atPath: staged?.path ?? ""))+ }++ @Test("Cancelling the share removes the staged file and returns to idle")+ @MainActor func shareCancellationCleansUp() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let (model, _) = makeSUT(subject: .entry(UUID()), stagingDirectory: dir) { mock in+ mock.entryExportInputResult = .success(entryInput())+ }+ await model.startExport()+ let staged = model.exportedFileURL++ model.handleShareCancellation()++ #expect(model.state == .idle)+ #expect(!FileManager.default.fileExists(atPath: staged?.path ?? ""))+ }++ // MARK: - Scavenging++ /// Backup export scavenges by filename prefix; markdown filenames come from+ /// the reader's own titles (Q11), so the staging *directory* is the only+ /// thing that identifies them (Q40).+ @Test("Stale markdown files are scavenged on init and fresh ones are kept")+ @MainActor func scavengesStaleFiles() {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let stale = dir.appending(path: "Old Chapter.md")+ let fresh = dir.appending(path: "New Chapter.md")+ try? Data("stale".utf8).write(to: stale)+ try? Data("fresh".utf8).write(to: fresh)+ try? FileManager.default.setAttributes(+ [.modificationDate: Date().addingTimeInterval(-48 * 3600)],+ ofItemAtPath: stale.path)++ _ = MarkdownExportModel(+ subject: .entry(UUID()), library: MockLibraryProvider(), stagingDirectory: dir)++ #expect(!FileManager.default.fileExists(atPath: stale.path))+ #expect(FileManager.default.fileExists(atPath: fresh.path))+ }++ // MARK: - Failure (privacy-safe)++ @Test("A read failure enters the failed state")+ @MainActor func readFailureFails() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let (model, _) = makeSUT(subject: .entry(UUID()), stagingDirectory: dir) { mock in+ mock.entryExportInputResult = .failure(+ MockLibraryProvider.MockError.simulatedFailure("boom"))+ }++ await model.startExport()++ #expect(model.state == .failed)+ #expect(model.exportedFileURL == nil)+ #expect(model.errorMessage != nil)+ }++ @Test("A vanished record is reported as gone rather than as a generic failure")+ @MainActor func vanishedRecordHasItsOwnMessage() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let id = UUID()+ let (model, _) = makeSUT(subject: .entry(id), stagingDirectory: dir) { mock in+ mock.entryExportInputResult = .failure(+ LibraryRepositoryError.recordNotFound(type: "Entry", id: id))+ }++ await model.startExport()++ #expect(model.state == .failed)+ let message = model.errorMessage ?? ""+ #expect(!message.isEmpty)+ // Privacy: never the identifier, never a path.+ #expect(!message.contains(id.uuidString))+ }++ /// The one hard rule of these messages: nothing the reader wrote and nothing+ /// about the filesystem leaves the model.+ @Test("Failure messages carry no titles, URLs, or paths")+ @MainActor func messagesArePrivacySafe() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let (model, _) = makeSUT(subject: .work(UUID()), stagingDirectory: dir) { mock in+ mock.workExportInputResult = .failure(+ MockLibraryProvider.MockError.simulatedFailure(+ "https://example.com/secret — The Perfect Run"))+ }++ await model.startExport()++ let message = model.errorMessage ?? ""+ #expect(!message.contains("https://"))+ #expect(!message.contains("file://"))+ #expect(!message.contains("The Perfect Run"))+ #expect(!message.contains("/var"))+ #expect(!message.contains(dir.path))+ }++ @Test("A staging failure fails rather than reporting a share")+ @MainActor func stagingFailureFails() async {+ // A staging directory the model cannot create: a *file* sits where the+ // directory would go.+ let blocked = FileManager.default.temporaryDirectory+ .appending(path: "markdown-export-blocked-\(UUID().uuidString)")+ try? Data("not a directory".utf8).write(to: blocked)+ defer { try? FileManager.default.removeItem(at: blocked) }++ let (model, _) = makeSUT(+ subject: .entry(UUID()),+ stagingDirectory: blocked.appending(path: "MarkdownExports")+ ) { mock in+ mock.entryExportInputResult = .success(entryInput())+ }++ await model.startExport()++ #expect(model.state == .failed)+ #expect(model.exportedFileURL == nil)+ }++ @Test("A retry after a failure can still succeed")+ @MainActor func retryAfterFailure() async {+ let dir = makeStagingDirectory()+ defer { try? FileManager.default.removeItem(at: dir) }+ let (model, mock) = makeSUT(subject: .entry(UUID()), stagingDirectory: dir) { mock in+ mock.entryExportInputResult = .failure(+ MockLibraryProvider.MockError.simulatedFailure("boom"))+ }+ await model.startExport()+ #expect(model.state == .failed)++ mock.entryExportInputResult = .success(entryInput())+ await model.startExport()++ #expect(model.state == .sharing)+ #expect(model.errorMessage == nil)+ }++ // MARK: - Staging location (design: Library/Caches/MarkdownExports)++ @Test("The staging directory sits under Library/Caches/MarkdownExports")+ func stagingDirectoryLocation() {+ let root = URL(filePath: "/tmp/library-root")+ let staging = MarkdownExportModel.stagingDirectory(inRoot: root)+ #expect(staging.path.hasSuffix("Library/Caches/MarkdownExports"))+ }+}
diff --git a/Asterism/AsterismTests/SearchFilterTests.swift b/Asterism/AsterismTests/SearchFilterTests.swiftnew file mode 100644index 0000000..c24d0db--- /dev/null+++ b/Asterism/AsterismTests/SearchFilterTests.swift@@ -0,0 +1,219 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++// Search on both tabs (Reqs 3, 4). Pure in-memory filtering (Q19), so the whole+// requirement is testable here without a view.++@Suite("Recent search filter")+struct RecentSearchFilterTests {++ private func group(_ day: Date, _ rows: [RecentPresentationRow]) -> RecentPresentationGroup {+ RecentPresentationGroup(day: day, rows: rows)+ }++ // MARK: - Inactive query (3.5)++ @Test("An empty query returns the groups unchanged")+ func emptyQueryIsInactive() {+ let groups = [group(TestFixtures.fixedDate, [TestFixtures.makeRecentRow()])]+ #expect(RecentSearchFilter(query: "").apply(to: groups) == groups)+ #expect(!RecentSearchFilter(query: "").isActive)+ }++ @Test("A whitespace-only query is inactive")+ func whitespaceQueryIsInactive() {+ let groups = [group(TestFixtures.fixedDate, [TestFixtures.makeRecentRow()])]+ #expect(RecentSearchFilter(query: " ").apply(to: groups) == groups)+ #expect(!RecentSearchFilter(query: " ").isActive)+ }++ // MARK: - Fields and folding (3.1, 3.3)++ @Test("Matches note text case-insensitively")+ func matchesNote() {+ let hit = TestFixtures.makeRecentRow(captureTitle: "A", note: "The Perfect Run is good")+ let miss = TestFixtures.makeRecentRow(captureTitle: "B", note: "nothing here")+ let result = RecentSearchFilter(query: "perfect").apply(+ to: [group(TestFixtures.fixedDate, [hit, miss])])+ #expect(result.count == 1)+ #expect(result[0].rows.map(\.id) == [hit.id])+ }++ @Test("Matches the work title diacritic-insensitively")+ func matchesWorkTitleFoldingDiacritics() {+ let hit = TestFixtures.makeRecentRow(+ captureTitle: "A", workDisplayTitle: "Café Chronicles")+ let result = RecentSearchFilter(query: "cafe").apply(+ to: [group(TestFixtures.fixedDate, [hit])])+ #expect(result.first?.rows.map(\.id) == [hit.id])+ }++ @Test("Matches the chapter title")+ func matchesChapterTitle() {+ let hit = TestFixtures.makeRecentRow(captureTitle: "raw", chapterTitle: "Chapter 14")+ let result = RecentSearchFilter(query: "chapter 1").apply(+ to: [group(TestFixtures.fixedDate, [hit])])+ #expect(result.first?.rows.map(\.id) == [hit.id])+ }++ /// Req 3.3: the cleaned capture title stands in for an absent chapter title.+ @Test("Falls back to the cleaned capture title when there is no chapter title")+ func fallsBackToCaptureTitle() {+ let hit = TestFixtures.makeRecentRow(captureTitle: "An Article About Bees")+ let result = RecentSearchFilter(query: "bees").apply(+ to: [group(TestFixtures.fixedDate, [hit])])+ #expect(result.first?.rows.map(\.id) == [hit.id])+ }++ /// "In place of", not "as well as": a row that has a chapter title is+ /// searched on that, and its raw capture title is not a second haystack.+ @Test("A row with a chapter title does not also match on its capture title")+ func captureTitleIsNotSearchedWhenAChapterTitleExists() {+ let row = TestFixtures.makeRecentRow(+ captureTitle: "Bees | SiteName", chapterTitle: "Chapter 14")+ let result = RecentSearchFilter(query: "SiteName").apply(+ to: [group(TestFixtures.fixedDate, [row])])+ #expect(result.isEmpty)+ }++ @Test("The hostname is not a searchable field")+ func hostnameDoesNotMatch() {+ let row = TestFixtures.makeRecentRow(captureTitle: "A", hostname: "royalroad.com")+ let result = RecentSearchFilter(query: "royalroad").apply(+ to: [group(TestFixtures.fixedDate, [row])])+ #expect(result.isEmpty)+ }++ // MARK: - Grouping (3.4)++ @Test("Day groups are preserved and emptied groups are dropped")+ func preservesGroupsAndDropsEmptyOnes() {+ let monday = TestFixtures.fixedDate+ let tuesday = TestFixtures.laterDate+ let hit = TestFixtures.makeRecentRow(captureTitle: "Bees")+ let miss = TestFixtures.makeRecentRow(captureTitle: "Wasps")+ let result = RecentSearchFilter(query: "bees").apply(to: [+ group(monday, [miss]),+ group(tuesday, [hit, miss]),+ ])+ #expect(result.count == 1)+ #expect(result[0].day == tuesday)+ #expect(result[0].rows.map(\.id) == [hit.id])+ }++ @Test("Row order inside a day is preserved")+ func preservesRowOrder() {+ let first = TestFixtures.makeRecentRow(captureTitle: "Bees one")+ let second = TestFixtures.makeRecentRow(captureTitle: "Bees two")+ let result = RecentSearchFilter(query: "bees").apply(+ to: [group(TestFixtures.fixedDate, [first, second])])+ #expect(result.first?.rows.map(\.id) == [first.id, second.id])+ }++ @Test("A query matching nothing yields no groups at all")+ func noMatchesYieldsNoGroups() {+ let result = RecentSearchFilter(query: "zzz").apply(+ to: [group(TestFixtures.fixedDate, [TestFixtures.makeRecentRow(captureTitle: "Bees")])])+ #expect(result.isEmpty)+ }++ // MARK: - Composition with the banner filters (3.2, Q6)++ /// The filter runs over the *already banner-filtered* groups, so the two+ /// conditions AND. Searching the unfiltered set here would surface a row the+ /// banner had excluded.+ @Test("Search narrows within an active banner filter rather than replacing it")+ func composesWithBannerFiltering() {+ let actionableHit = TestFixtures.makeRecentRow(captureTitle: "Bees", isActionable: true)+ let settledHit = TestFixtures.makeRecentRow(captureTitle: "Bees elsewhere")+ let actionableMiss = TestFixtures.makeRecentRow(captureTitle: "Wasps", isActionable: true)+ let all = [group(TestFixtures.fixedDate, [actionableHit, settledHit, actionableMiss])]++ let bannerFiltered = all.compactMap { group -> RecentPresentationGroup? in+ let rows = group.rows.filter(\.isActionable)+ return rows.isEmpty ? nil : RecentPresentationGroup(day: group.day, rows: rows)+ }+ let result = RecentSearchFilter(query: "bees").apply(to: bannerFiltered)++ #expect(result.flatMap(\.rows).map(\.id) == [actionableHit.id])+ }+}++@Suite("Works search filter")+struct WorksSearchFilterTests {++ private func snapshot(+ works: [WorkSnapshot], unattached: [EntrySnapshot] = []+ ) -> WorksSnapshot {+ WorksSnapshot(works: works, unattachedEntries: unattached)+ }++ @Test("An empty query returns the snapshot unchanged")+ func emptyQueryIsInactive() {+ let input = snapshot(+ works: [TestFixtures.makeWork(displayTitle: "A")],+ unattached: [TestFixtures.makeEntry(captureTitle: "loose")])+ #expect(WorksSearchFilter(query: "").apply(to: input) == input)+ #expect(!WorksSearchFilter(query: "").isActive)+ }++ @Test("Matches the display title case- and diacritic-insensitively")+ func matchesDisplayTitle() {+ let hit = TestFixtures.makeWork(displayTitle: "Café Chronicles")+ let miss = TestFixtures.makeWork(displayTitle: "The Perfect Run")+ let result = WorksSearchFilter(query: "cafe chron").apply(+ to: snapshot(works: [hit, miss]))+ #expect(result.works.map(\.id) == [hit.id])+ }++ /// Req 4.1: the list's own ordering survives, empty works still sinking —+ /// the filter only removes, it never reorders.+ @Test("Existing ordering is preserved, including empty works sinking")+ func preservesOrdering() {+ let full = TestFixtures.makeWork(+ displayTitle: "Bee Chronicles", entries: [TestFixtures.makeEntry()])+ let alsoFull = TestFixtures.makeWork(+ displayTitle: "Bees Abroad", entries: [TestFixtures.makeEntry()])+ let empty = TestFixtures.makeWork(displayTitle: "Bee Fragments")+ let result = WorksSearchFilter(query: "bee").apply(+ to: snapshot(works: [full, alsoFull, empty]))+ #expect(result.works.map(\.id) == [full.id, alsoFull.id, empty.id])+ }++ @Test("Generic notes are not a searchable field on Works")+ func genericNotesDoNotMatch() {+ let work = TestFixtures.makeWork(displayTitle: "A", genericNotes: "all about bees")+ let result = WorksSearchFilter(query: "bees").apply(to: snapshot(works: [work]))+ #expect(result.works.isEmpty)+ }++ /// Req 4.2, Q12: the group is hidden outright while a query is active, so an+ /// unattached entry can never surface from a work-title search.+ @Test("The unattached group is hidden while a query is active")+ func hidesUnattachedGroupWhileSearching() {+ let input = snapshot(+ works: [TestFixtures.makeWork(displayTitle: "Bees")],+ unattached: [TestFixtures.makeEntry(captureTitle: "Bees loose note")])+ let result = WorksSearchFilter(query: "bees").apply(to: input)+ #expect(result.works.count == 1)+ #expect(result.unattachedEntries.isEmpty)+ }++ @Test("Clearing the query restores the unattached group")+ func clearingRestoresUnattached() {+ let input = snapshot(+ works: [TestFixtures.makeWork(displayTitle: "Bees")],+ unattached: [TestFixtures.makeEntry(captureTitle: "loose")])+ #expect(WorksSearchFilter(query: "").apply(to: input).unattachedEntries.count == 1)+ }++ @Test("A query matching no work yields an empty works list")+ func noMatches() {+ let result = WorksSearchFilter(query: "zzz").apply(+ to: snapshot(works: [TestFixtures.makeWork(displayTitle: "Bees")]))+ #expect(result.works.isEmpty)+ #expect(result.unattachedEntries.isEmpty)+ }+}
diff --git a/Asterism/AsterismTests/SitesModelTests.swift b/Asterism/AsterismTests/SitesModelTests.swiftnew file mode 100644index 0000000..fee8c24--- /dev/null+++ b/Asterism/AsterismTests/SitesModelTests.swift@@ -0,0 +1,297 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++// The Sites settings screen (Req 6). Wording lives in the models, per the house+// convention, so these tests are where the screen's sentences are pinned.++@Suite("Sites list model")+struct SitesListModelTests {++ private func snapshot(+ hostname: String, displayName: String? = nil, mode: SiteMode+ ) -> SiteSnapshot {+ SiteSnapshot.fixture(+ hostname: hostname, displayName: displayName ?? hostname, mode: mode)+ }++ @Test("Load exposes one row per site with its name, hostname, and mode")+ @MainActor func loadExposesRows() async {+ let mock = MockLibraryProvider()+ mock.sitesResult = .success([+ snapshot(hostname: "royalroad.com", displayName: "Royal Road", mode: .taught),+ snapshot(hostname: "news.example", displayName: "News", mode: .articles),+ snapshot(hostname: "unknown.test", mode: .untaught),+ ])+ let model = SitesListModel(library: mock)++ await model.load()++ #expect(model.state == .ready)+ #expect(model.rows.map(\.hostname) == ["royalroad.com", "news.example", "unknown.test"])+ #expect(model.rows.map(\.displayName) == ["Royal Road", "News", "unknown.test"])+ // Distinct, plain-language labels — the reader is choosing what to do+ // next from them.+ #expect(Set(model.rows.map(\.modeLabel)).count == 3)+ }++ /// Q10: the untaught sites are exactly the ones the screen exists to send+ /// the reader to teach, so they are never filtered out.+ @Test("Untaught sites are listed, not hidden")+ @MainActor func untaughtSitesAreListed() async {+ let mock = MockLibraryProvider()+ mock.sitesResult = .success([snapshot(hostname: "new.test", mode: .untaught)])+ let model = SitesListModel(library: mock)++ await model.load()++ #expect(model.rows.count == 1)+ }++ @Test("An empty store reports having no sites rather than an empty screen")+ @MainActor func emptyStore() async {+ let mock = MockLibraryProvider()+ mock.sitesResult = .success([])+ let model = SitesListModel(library: mock)++ await model.load()++ #expect(model.state == .ready)+ #expect(model.rows.isEmpty)+ #expect(!model.emptyMessage.isEmpty)+ }++ @Test("A failed read enters the error state")+ @MainActor func failedRead() async {+ let mock = MockLibraryProvider()+ mock.sitesResult = .failure(MockLibraryProvider.MockError.simulatedFailure("nope"))+ let model = SitesListModel(library: mock)++ await model.load()++ if case .error = model.state {} else { Issue.record("Expected the error state") }+ #expect(model.rows.isEmpty)+ }+}++@Suite("Site detail model")+struct SiteDetailModelTests {++ private func snapshot(+ hostname: String = "royalroad.com", mode: SiteMode+ ) -> SiteSnapshot {+ SiteSnapshot.fixture(hostname: hostname, displayName: "Royal Road", mode: mode)+ }++ /// Built through the planner teach mode uses, not hand-assembled: the count+ /// the prompt shows has to be the one a real projection would produce.+ private func articlesContract(entryCount: Int) -> ArticlesContract {+ let entries = (0..<entryCount).map { index in+ TestFixtures.makeEntry(captureTitle: "Page \(index)", hostname: "royalroad.com")+ }+ return ArticlesContract(+ basis: TeachingBasis(+ siteMode: .taught, hostname: "royalroad.com", patterns: [], entries: [],+ works: []),+ request: ArticlesRequest(junkSuffixRule: nil),+ outcome: ArticlesOutcome(+ plan: TitleProjectionPlanner.planArticles(+ entries: entries, junkSuffixRule: nil)))+ }++ @MainActor private func makeSUT(+ mode: SiteMode = .taught,+ canReteach: Bool = true+ ) -> (SiteDetailModel, MockLibraryProvider, ReteachRecorder) {+ let mock = MockLibraryProvider()+ let recorder = ReteachRecorder()+ let model = SiteDetailModel(+ site: snapshot(mode: mode),+ library: mock,+ canReteach: canReteach,+ onReteach: { hostname, permits in recorder.record(hostname, permits) },+ onMutation: {})+ return (model, mock, recorder)+ }++ // MARK: - Re-teach (Req 6.2, 6.3)++ @Test("Re-teaching a taught site routes without the articles-conversion flag")+ @MainActor func reteachTaughtSite() {+ let (model, _, recorder) = makeSUT(mode: .taught)++ #expect(model.canReteach)+ #expect(model.reteachUnavailableMessage == nil)+ model.reteach()++ #expect(recorder.hostnames == ["royalroad.com"])+ #expect(recorder.permits == [false])+ }++ /// Decision 3: the Sites screen is the **only** route allowed to convert an+ /// articles site back, so it is the only one that sets the flag.+ @Test("Re-teaching an articles site carries the conversion flag")+ @MainActor func reteachArticlesSite() {+ let (model, _, recorder) = makeSUT(mode: .articles)++ model.reteach()++ #expect(recorder.permits == [true])+ }++ /// The composed surface is entered from an Entry, so a site with nothing+ /// captured from it has nothing to teach against — and the screen says so+ /// rather than offering a button that does nothing.+ @Test("A site with no entries cannot be re-taught and explains why")+ @MainActor func reteachUnavailableWithoutEntries() {+ let (model, _, recorder) = makeSUT(mode: .untaught, canReteach: false)++ #expect(!model.canReteach)+ #expect(model.reteachUnavailableMessage != nil)+ model.reteach()++ #expect(recorder.hostnames.isEmpty)+ }++ // MARK: - Articles switch (Req 6.3, 6.4)++ @Test("A taught site offers the articles switch; an articles site does not")+ @MainActor func articlesSwitchAvailability() {+ #expect(makeSUT(mode: .taught).0.canSwitchToArticles)+ #expect(makeSUT(mode: .untaught).0.canSwitchToArticles)+ #expect(!makeSUT(mode: .articles).0.canSwitchToArticles)+ }++ @Test("Requesting the switch projects it and names the entry count")+ @MainActor func requestArticlesProjects() async {+ let (model, mock, _) = makeSUT(mode: .taught)+ mock.projectArticlesResult = .success(articlesContract(entryCount: 3))++ await model.requestArticlesSwitch()++ #expect(mock.projectArticlesCallCount == 1)+ #expect(model.articlesPrompt?.entryCount == 3)+ #expect(model.articlesPrompt?.message.contains("3") == true)+ #expect(mock.commitArticlesCallCount == 0)+ }++ /// Req 6.4: the same contracts teach mode uses, so the retroactive semantics+ /// are identical by construction rather than by re-implementation.+ @Test("Confirming commits the projected contract and reports the mutation")+ @MainActor func confirmArticlesCommits() async throws {+ let mock = MockLibraryProvider()+ let mutations = MutationRecorder()+ let model = SiteDetailModel(+ site: snapshot(mode: .taught), library: mock, canReteach: true,+ onReteach: { _, _ in }, onMutation: { mutations.record() })+ let contract = articlesContract(entryCount: 2)+ mock.projectArticlesResult = .success(contract)+ mock.commitArticlesResult = .success(.committed)++ await model.requestArticlesSwitch()+ let prompt = try #require(model.articlesPrompt)+ await model.confirmArticlesSwitch(prompt)++ #expect(mock.commitArticlesCallCount == 1)+ #expect(mock.lastCommittedArticlesContract == contract)+ #expect(model.didSwitchToArticles)+ #expect(mutations.count == 1)+ #expect(model.articlesPrompt == nil)+ }++ @Test("Cancelling the switch writes nothing")+ @MainActor func cancelArticles() async {+ let (model, mock, _) = makeSUT(mode: .taught)+ mock.projectArticlesResult = .success(articlesContract(entryCount: 1))+ await model.requestArticlesSwitch()++ model.cancelArticlesSwitch()++ #expect(model.articlesPrompt == nil)+ #expect(mock.commitArticlesCallCount == 0)+ }++ /// Regression, found by `SitesSettingsUITests`: the confirm button did+ /// nothing at all on the real screen. SwiftUI runs a `confirmationDialog`'s+ /// `isPresented` setter — the dismissal, which is `cancelArticlesSwitch()` —+ /// *before* the tapped button's action, and the commit used to read the+ /// contract back off the model, where the dismissal had already cleared it.+ /// The tests above never saw it because they call the two methods in the+ /// order a reader would expect rather than the order SwiftUI uses.+ @Test("A dismissal running before the confirm action still commits (Q101)")+ @MainActor func confirmSurvivesTheDismissalRunningFirst() async throws {+ let (model, mock, _) = makeSUT(mode: .taught)+ let contract = articlesContract(entryCount: 2)+ mock.projectArticlesResult = .success(contract)+ mock.commitArticlesResult = .success(.committed)+ await model.requestArticlesSwitch()+ let prompt = try #require(model.articlesPrompt)++ // SwiftUI's order, not the reader's.+ model.cancelArticlesSwitch()+ await model.confirmArticlesSwitch(prompt)++ #expect(mock.commitArticlesCallCount == 1)+ #expect(mock.lastCommittedArticlesContract == contract)+ #expect(model.didSwitchToArticles)+ }++ @Test("A refreshed commit re-presents the fresh counts instead of switching")+ @MainActor func refreshedRepresents() async throws {+ let (model, mock, _) = makeSUT(mode: .taught)+ mock.projectArticlesResult = .success(articlesContract(entryCount: 1))+ mock.commitArticlesResult = .success(.refreshed(articlesContract(entryCount: 5)))++ await model.requestArticlesSwitch()+ let prompt = try #require(model.articlesPrompt)+ await model.confirmArticlesSwitch(prompt)++ #expect(!model.didSwitchToArticles)+ #expect(model.articlesPrompt?.entryCount == 5)+ #expect(model.errorMessage != nil)+ }++ @Test("An invalidated commit reports the repository's reason")+ @MainActor func invalidatedReportsReason() async throws {+ let (model, mock, _) = makeSUT(mode: .taught)+ mock.projectArticlesResult = .success(articlesContract(entryCount: 1))+ mock.commitArticlesResult = .success(.invalidated(reason: "This site is quarantined."))++ await model.requestArticlesSwitch()+ let prompt = try #require(model.articlesPrompt)+ await model.confirmArticlesSwitch(prompt)++ #expect(!model.didSwitchToArticles)+ #expect(model.errorMessage == "This site is quarantined.")+ }++ @Test("A projection failure is reported without a commit")+ @MainActor func projectionFailure() async {+ let (model, mock, _) = makeSUT(mode: .taught)+ mock.projectArticlesResult = .failure(+ MockLibraryProvider.MockError.simulatedFailure("nope"))++ await model.requestArticlesSwitch()++ #expect(model.articlesPrompt == nil)+ #expect(model.errorMessage != nil)+ #expect(mock.commitArticlesCallCount == 0)+ }+}++/// Records the re-teach routes a site detail hands back to its host.+final class ReteachRecorder: @unchecked Sendable {+ private(set) var hostnames: [String] = []+ private(set) var permits: [Bool] = []++ func record(_ hostname: String, _ permitsArticlesConversion: Bool) {+ hostnames.append(hostname)+ permits.append(permitsArticlesConversion)+ }+}++final class MutationRecorder: @unchecked Sendable {+ private(set) var count = 0+ func record() { count += 1 }+}
diff --git a/Asterism/AsterismTests/WorkDetailModelTests.swift b/Asterism/AsterismTests/WorkDetailModelTests.swiftindex 1cbbc53..b64a743 100644--- a/Asterism/AsterismTests/WorkDetailModelTests.swift+++ b/Asterism/AsterismTests/WorkDetailModelTests.swift@@ -10,7 +10,8 @@ struct WorkDetailModelTests { @MainActor private func makeSUT( work: WorkSnapshot? = nil,- updateResult: Result<LibraryWriteOutcome, Error> = .success(.committed)+ updateResult: Result<LibraryWriteOutcome, Error> = .success(.committed),+ detail: WorkDetailPresentation? = nil ) -> (WorkDetailModel, MockLibraryProvider, CallbackTracker) { let mock = MockLibraryProvider() let workSnapshot = work ?? TestFixtures.makeWork(@@ -20,6 +21,8 @@ struct WorkDetailModelTests { genericNotes: "some notes" ) mock.workResult = .success(workSnapshot)+ mock.workDetailResult = .success(+ detail ?? TestFixtures.makeWorkDetail(work: workSnapshot)) mock.updateWorkResult = updateResult let tracker = CallbackTracker()@@ -149,4 +152,417 @@ struct WorkDetailModelTests { #expect(mock.lastUpdateWorkBasis?.lastParsedTitle == work.lastParsedTitle) #expect(mock.lastUpdateWorkBasis?.titleProvenance == work.titleProvenance) }++ // MARK: - The §6 read (Reqs 5.1–5.4)++ /// One read, not two: the pulse, the last-noted URL and the chapter rows all+ /// come from `workDetail(id:)`, so they describe one moment of the store.+ @MainActor @Test("Load adopts the workDetail read and exposes its three parts")+ func loadAdoptsWorkDetail() async {+ let entryA = TestFixtures.makeEntry(+ captureTitle: "Ch 1", rating: .up, lastSharedAt: TestFixtures.earlierDate)+ let entryB = TestFixtures.makeEntry(+ captureTitle: "Ch 2", rating: .down, lastSharedAt: TestFixtures.laterDate)+ let work = TestFixtures.makeWork(displayTitle: "Serial", entries: [entryB, entryA])+ let (model, mock, _) = makeSUT(work: work)++ await model.load()++ #expect(mock.workDetailCallCount == 1)+ #expect(mock.workCallCount == 0)+ #expect(model.work == work)+ #expect(model.pulse == RatingPulse(notes: 2, up: 1, down: 1))+ #expect(model.lastNotedURLString == entryB.rawURLString)+ #expect(model.chapterRows.map(\.id) == [entryB.id, entryA.id])+ }++ /// Req 5.3: no entries, no action — the model reports nothing to open+ /// rather than the screen deciding what an empty work means.+ @MainActor @Test("An empty work exposes no last-noted URL and an all-zero pulse")+ func emptyWorkHasNoLastNotedURL() async {+ let work = TestFixtures.makeWork(displayTitle: "Nothing yet")+ let (model, _, _) = makeSUT(work: work)++ await model.load()++ #expect(model.lastNotedURLString == nil)+ #expect(model.pulse == RatingPulse(notes: 0, up: 0, down: 0))+ #expect(model.chapterRows.isEmpty)+ }++ @MainActor @Test("A failed read leaves the screen in the error state")+ func failedDetailReadErrors() async {+ let mock = MockLibraryProvider()+ mock.workDetailResult = .failure(+ MockLibraryProvider.MockError.simulatedFailure("read failed"))+ let model = WorkDetailModel(workID: UUID(), library: mock, onMutation: {})++ await model.load()++ if case .error = model.state {} else { Issue.record("Expected the error state") }+ #expect(model.work == nil)+ }++ // MARK: - Deletion (Req 7.1, 7.2)++ @MainActor private func contract(+ work: WorkSnapshot,+ entryGroupIDs: Set<UUID> = [],+ tornEntryVariants: [UUID: [AuthoredVariant<EntryAuthoredContent>]] = [:]+ ) -> WorkDeletionContract {+ WorkDeletionContract(+ basis: WorkEditBasis(work: work),+ workID: work.id,+ entryGroupIDs: entryGroupIDs,+ tornEntryVariants: tornEntryVariants)+ }++ private func variant(note: String) -> AuthoredVariant<EntryAuthoredContent> {+ AuthoredVariant(+ content: EntryAuthoredContent(note: note, rating: .up),+ firstCapturedAt: TestFixtures.fixedDate)+ }++ /// Answers the dialog the way its `presenting:` closure does: with the+ /// prompt the dialog was rendered from.+ @MainActor private func choose(+ _ disposition: WorkDeletionDisposition, on model: WorkDetailModel+ ) async {+ guard let prompt = model.deletionPrompt else {+ Issue.record("Expected a deletion prompt")+ return+ }+ await model.chooseDeletion(prompt, disposition: disposition)+ }++ /// Req 7.1: the prompt is projected first, so the counts it names are the+ /// store's rather than the loaded snapshot's.+ @MainActor @Test("Requesting deletion projects the contract and raises the prompt")+ func requestDeleteProjects() async {+ let work = TestFixtures.makeWork(displayTitle: "Doomed")+ let (model, mock, _) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(+ contract(work: work, entryGroupIDs: [UUID(), UUID()]))+ await model.load()++ await model.requestDelete()++ #expect(mock.projectWorkDeletionCallCount == 1)+ #expect(model.deletionPrompt?.entryCount == 2)+ #expect(model.deleteDisclosure == nil)+ }++ @MainActor @Test("Cancelling the prompt writes nothing")+ func cancelDeleteWritesNothing() async {+ let work = TestFixtures.makeWork()+ let (model, mock, _) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(contract(work: work))+ await model.load()+ await model.requestDelete()++ model.cancelDelete()++ #expect(model.deletionPrompt == nil)+ #expect(mock.commitWorkDeletionCallCount == 0)+ }++ @MainActor @Test("Choosing delete-entries commits that disposition and dismisses")+ func deleteEntriesCommits() async {+ let work = TestFixtures.makeWork()+ let (model, mock, tracker) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(contract(work: work, entryGroupIDs: [UUID()]))+ await model.load()+ await model.requestDelete()++ await choose(.deleteEntries, on: model)++ #expect(mock.commitWorkDeletionCallCount == 1)+ #expect(mock.lastWorkDeletionDisposition == .deleteEntries)+ #expect(mock.lastWorkDeletionDisclosedVariants == .some(nil))+ #expect(model.isDeleted)+ #expect(tracker.mutationCount == 1)+ #expect(model.deletionPrompt == nil)+ }++ @MainActor @Test("Choosing detach commits the detach disposition")+ func detachCommits() async {+ let work = TestFixtures.makeWork()+ let (model, mock, _) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(contract(work: work, entryGroupIDs: [UUID()]))+ await model.load()+ await model.requestDelete()++ await choose(.detachEntries, on: model)++ #expect(mock.lastWorkDeletionDisposition == .detachEntries)+ #expect(model.isDeleted)+ }++ /// Req 7.2: the disclosure gate applies to **both** dispositions — deleting+ /// destroys authored variants and detaching authors over them.+ @MainActor @Test(+ "A torn entry group routes both dispositions through the disclosure first",+ arguments: [WorkDeletionDisposition.deleteEntries, .detachEntries])+ func tornGroupDisclosesFirst(_ disposition: WorkDeletionDisposition) async {+ let work = TestFixtures.makeWork()+ let groupID = UUID()+ let (model, mock, _) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(+ contract(+ work: work, entryGroupIDs: [groupID],+ tornEntryVariants: [groupID: [variant(note: "one"), variant(note: "two")]]))+ await model.load()+ await model.requestDelete()++ await choose(disposition, on: model)++ #expect(mock.commitWorkDeletionCallCount == 0)+ #expect(model.deleteDisclosure?.disposition == disposition)+ #expect(model.deleteDisclosure?.lines.count == 2)+ #expect(!model.isDeleted)+ }++ /// The disclosure is a **parameter**, mirroring entry deletion: SwiftUI+ /// clears an alert's binding before running its action, so a commit that+ /// re-read the property would find it nil and disclose nothing.+ @MainActor @Test("Confirming the disclosure hands the commit exactly what was shown")+ func confirmingDisclosurePassesTheShownVariants() async {+ let work = TestFixtures.makeWork()+ let groupID = UUID()+ let variants = [variant(note: "one"), variant(note: "two")]+ let (model, mock, _) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(+ contract(+ work: work, entryGroupIDs: [groupID], tornEntryVariants: [groupID: variants]))+ await model.load()+ await model.requestDelete()+ await choose(.deleteEntries, on: model)+ guard let disclosure = model.deleteDisclosure else {+ Issue.record("Expected a disclosure")+ return+ }++ await model.confirmDeletion(disclosure)++ #expect(mock.lastWorkDeletionDisclosedVariants == .some(Set(variants.map(\.id))))+ #expect(model.isDeleted)+ }++ // MARK: - The dismissal-before-action order (the real UI's sequence)++ /// The order SwiftUI actually uses, and the bug it caused: tapping a+ /// confirmation-dialog button dismisses the dialog first — running the+ /// `isPresented` setter, which is `cancelDelete()` — and only then runs the+ /// button's action. A `chooseDeletion` that re-read model state found it+ /// already cleared and deleted nothing, silently.+ @MainActor @Test("A dialog button still commits after the dismissal has cleared the model")+ func dialogButtonCommitsAfterTheDismissal() async {+ let work = TestFixtures.makeWork()+ let (model, mock, tracker) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(contract(work: work, entryGroupIDs: [UUID()]))+ await model.load()+ await model.requestDelete()+ guard let prompt = model.deletionPrompt else {+ Issue.record("Expected a deletion prompt")+ return+ }++ // SwiftUI's order: the dialog goes away, then the button acts.+ model.cancelDelete()+ await model.chooseDeletion(prompt, disposition: .deleteEntries)++ #expect(mock.commitWorkDeletionCallCount == 1)+ #expect(mock.lastWorkDeletionDisposition == .deleteEntries)+ #expect(model.isDeleted)+ #expect(tracker.mutationCount == 1)+ }++ /// The same order one level down, over both alerts in sequence: the dialog+ /// dismisses before it raises the disclosure, and the disclosure dismisses+ /// before its Continue commits.+ @MainActor @Test("A disclosure button still commits after the alert's dismissal")+ func disclosureButtonCommitsAfterTheDismissal() async {+ let work = TestFixtures.makeWork()+ let groupID = UUID()+ let variants = [variant(note: "one"), variant(note: "two")]+ let (model, mock, _) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(+ contract(+ work: work, entryGroupIDs: [groupID], tornEntryVariants: [groupID: variants]))+ await model.load()+ await model.requestDelete()+ guard let prompt = model.deletionPrompt else {+ Issue.record("Expected a deletion prompt")+ return+ }++ model.cancelDelete()+ await model.chooseDeletion(prompt, disposition: .deleteEntries)+ guard let disclosure = model.deleteDisclosure else {+ Issue.record("Expected a disclosure")+ return+ }++ model.cancelDeletionDisclosure()+ await model.confirmDeletion(disclosure)++ #expect(mock.commitWorkDeletionCallCount == 1)+ #expect(mock.lastWorkDeletionDisclosedVariants == .some(Set(variants.map(\.id))))+ #expect(model.isDeleted)+ }++ // MARK: - Deletion outcomes++ /// Q28: the prompt named an entry count, so a changed group set re-presents+ /// rather than deleting more than the reader confirmed.+ @MainActor @Test("A refreshed commit re-presents the prompt with the fresh counts")+ func refreshedRepresentsThePrompt() async {+ let work = TestFixtures.makeWork()+ let fresh = contract(work: work, entryGroupIDs: [UUID(), UUID(), UUID()])+ let (model, mock, tracker) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(contract(work: work, entryGroupIDs: [UUID()]))+ mock.commitWorkDeletionResult = .success(.refreshed(fresh))+ await model.load()+ await model.requestDelete()++ await choose(.deleteEntries, on: model)++ #expect(!model.isDeleted)+ #expect(model.deletionPrompt?.entryCount == 3)+ #expect(model.errorMessage != nil)+ #expect(tracker.mutationCount == 0)+ }++ /// A stale disclosure means a copy arrived while the alert was up: the+ /// contract is re-projected and the reader answers over the current copies.+ @MainActor @Test("A stale disclosure re-projects rather than deleting")+ func staleDisclosureReprojects() async {+ let work = TestFixtures.makeWork()+ let groupID = UUID()+ let (model, mock, _) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(+ contract(+ work: work, entryGroupIDs: [groupID],+ tornEntryVariants: [groupID: [variant(note: "one")]]))+ mock.commitWorkDeletionResult = .success(+ .conflict(.disclosureStale(recordID: work.id, variants: [])))+ await model.load()+ await model.requestDelete()+ await choose(.deleteEntries, on: model)+ guard let disclosure = model.deleteDisclosure else {+ Issue.record("Expected a disclosure")+ return+ }++ await model.confirmDeletion(disclosure)++ #expect(!model.isDeleted)+ #expect(model.errorMessage != nil)+ // Re-projected: the first projection was the request, the second the+ // re-presentation.+ #expect(mock.projectWorkDeletionCallCount == 2)+ }++ @MainActor @Test("A conflict reaches the conflict sink rather than being swallowed")+ func conflictReachesTheSink() async {+ let work = TestFixtures.makeWork()+ let mock = MockLibraryProvider()+ mock.workDetailResult = .success(TestFixtures.makeWorkDetail(work: work))+ mock.projectWorkDeletionResult = .success(+ WorkDeletionContract(+ basis: WorkEditBasis(work: work), workID: work.id,+ entryGroupIDs: [], tornEntryVariants: [:]))+ mock.commitWorkDeletionResult = .success(+ .conflict(.torn(recordID: work.id, variants: [])))+ let sink = ConflictSink()+ let model = WorkDetailModel(+ workID: work.id, library: mock, onMutation: {},+ onConflict: { conflict in sink.record(conflict) })+ await model.load()+ await model.requestDelete()++ await choose(.deleteEntries, on: model)++ #expect(sink.count == 1)+ #expect(!model.isDeleted)+ }++ @MainActor @Test("An invalidated commit reports the reason and keeps the screen")+ func invalidatedReportsTheReason() async {+ let work = TestFixtures.makeWork()+ let (model, mock, _) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(contract(work: work))+ mock.commitWorkDeletionResult = .success(+ .invalidated(reason: "The library check refused this deletion."))+ await model.load()++ await model.requestDelete()+ await choose(.deleteEntries, on: model)++ #expect(!model.isDeleted)+ #expect(model.errorMessage == "The library check refused this deletion.")+ }++ /// A projection that fails raises no prompt, so there is nothing to confirm+ /// and nothing to commit — the reader is told instead.+ @MainActor @Test("A projection failure is reported without a commit")+ func projectionFailureReportsWithoutCommitting() async {+ let work = TestFixtures.makeWork()+ let (model, mock, _) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .failure(+ MockLibraryProvider.MockError.simulatedFailure("projection failed"))+ await model.load()++ await model.requestDelete()++ #expect(model.deletionPrompt == nil)+ #expect(model.errorMessage != nil)+ #expect(mock.commitWorkDeletionCallCount == 0)+ }++ /// Deletion idempotence at the screen: the work was already gone by the time+ /// the commit ran, the repository maps that to `.committed`, and the screen+ /// leaves rather than reporting a failure over something already true.+ @MainActor @Test("An already-gone work commits as deleted and leaves the screen")+ func goneWorkCommitsAsDeleted() async {+ let work = TestFixtures.makeWork()+ let (model, mock, tracker) = makeSUT(work: work)+ mock.projectWorkDeletionResult = .success(contract(work: work, entryGroupIDs: [UUID()]))+ mock.commitWorkDeletionResult = .success(.committed)+ await model.load()+ await model.requestDelete()++ await choose(.deleteEntries, on: model)++ #expect(model.isDeleted)+ #expect(model.errorMessage == nil)+ #expect(model.state == .ready)+ #expect(tracker.mutationCount == 1)+ }++ // MARK: - The Edit-details split (Q20)++ /// The sheet is a *view* split: the model's draft/commit surface is+ /// unchanged, so the Work-URL machinery it hosts still round-trips.+ @MainActor @Test("The Edit-details surface keeps the save and Work-URL paths intact")+ func editDetailsSplitKeepsTheModelSurface() async {+ let work = TestFixtures.makeWork(displayTitle: "Serial")+ let (model, mock, tracker) = makeSUT(work: work)+ await model.load()++ model.draftTitle = "Renamed"+ await model.save()++ #expect(mock.updateWorkCallCount == 1)+ #expect(tracker.mutationCount == 1)+ // Still the same reload path, and still the §6 read.+ #expect(mock.workDetailCallCount == 2)+ }+}++/// Records the conflicts a model hands off, from the `@Sendable` closure the+/// model takes.+final class ConflictSink: @unchecked Sendable {+ private(set) var count = 0+ func record(_ conflict: WriteConflict) { count += 1 } }
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex 3c0ad3a..e42d333 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -49,12 +49,22 @@ final class AccessibilityJourneyUITests: XCTestCase { XCTAssertTrue(workTitle.waitForExistence(timeout: 10)) workTitle.tap() - let assignedEntry = app.staticTexts.matching(identifier: "work-detail-entry").firstMatch+ let assignedEntry = app.descendants(matching: .any)+ .matching(identifier: "work-detail-entry").firstMatch scrollToElement(assignedEntry) XCTAssertTrue(assignedEntry.waitForExistence(timeout: 10))++ // Save is a toolbar confirmation that appears only while a draft+ // differs from the loaded snapshot (Req 5.6's in-place edits), so the+ // journey has to dirty the title before it can assert the control.+ let titleField = app.textFields["work-detail-title-field"]+ scrollToElement(titleField)+ XCTAssertTrue(titleField.waitForExistence(timeout: 10), "Editable work title should exist")+ titleField.tap()+ titleField.typeText("!") let saveWork = app.buttons["work-detail-save-button"]- scrollToElement(saveWork)- assertContentControl(saveWork, named: "Save Work")+ XCTAssertTrue(saveWork.waitForExistence(timeout: 10), "Save should appear once the title differs")+ assertSystemControl(saveWork, named: "Save Work") } @MainActor@@ -166,6 +176,98 @@ final class AccessibilityJourneyUITests: XCTestCase { assertSystemControl(newWork, named: "New Work") } + // MARK: - Constellation visual pass (Reqs 8–11)++ /// The Works tab and Work detail in dark, walked through the surfaces the+ /// Constellation adoption restructured most: the carded work rows, the+ /// unattached-notes header (which has to stay a *static text* element, so a+ /// section header rendered as a container would fail here), and the §6+ /// rating-pulse cards. Identifiers are unchanged by the visual pass, and+ /// this is what says so.+ @MainActor+ func testWorksTabAppearanceJourneyInDark() {+ launchSeeded(extraArguments: ["-AppleInterfaceStyle", "Dark"])+ walkWorksAppearanceJourney(named: "dark")+ }++ /// The same journey in light — requirement 8.2's "no screen renders+ /// dark-variant tokens in light mode" has no pixel assertion behind it, but+ /// a token that failed to resolve would take the surface's contents with it,+ /// and this journey would stop finding them.+ @MainActor+ func testWorksTabAppearanceJourneyInLight() {+ launchSeeded(extraArguments: ["-AppleInterfaceStyle", "Light"])+ walkWorksAppearanceJourney(named: "light")+ }++ /// Requirement 11.1's Reduce Transparency fallback, as far as a simulator+ /// UI test can reach it.+ ///+ /// **The setting itself is not reachable from here** (Q44): neither the+ /// `-UIAccessibilityReduceTransparency YES` launch argument this suite has+ /// always passed nor writing `com.apple.Accessibility` defaults into the+ /// booted device changes what `accessibilityReduceTransparency` reports —+ /// both were measured against the rendered pixels and the row kept its+ /// material either way. So this journey pins what it can: with the flag set+ /// the restructured surfaces are all still reachable under their unchanged+ /// identifiers. Whether the opaque fill *replaces* the material rather than+ /// layering under it is settled in `ConstellationCard` and+ /// `ConstellationSheetSurface`, where the branch has no fall-through.+ @MainActor+ func testReduceTransparencyJourneyKeepsRestructuredSurfacesReachable() {+ launchSeeded(+ extraArguments: [+ "-AppleInterfaceStyle", "Dark",+ "-UIAccessibilityReduceTransparency", "YES",+ ]+ )++ XCTAssertTrue(+ app.collectionViews["recent-list"].waitForExistence(timeout: 30),+ "Recent's carded rows should render under Reduce Transparency")+ assertContentControl(app.buttons["actionable-banner"], named: "Actionable banner")+ assertContentControl(app.buttons["teach-pill"], named: "Teach pill")++ openSeededEntry()+ let update = app.buttons["entry-detail-update-button"]+ scrollToElement(update)+ assertContentControl(update, named: "Update under Reduce Transparency")+ }++ private func walkWorksAppearanceJourney(named appearance: String) {+ let works = app.tabBars.buttons["Works"]+ XCTAssertTrue(works.waitForExistence(timeout: 30), "Works tab missing in \(appearance)")+ works.tap()++ XCTAssertTrue(+ app.collectionViews["works-list"].waitForExistence(timeout: 10),+ "Works list missing in \(appearance)")+ XCTAssertTrue(+ app.staticTexts["unattached-section-header"].waitForExistence(timeout: 10),+ "The unattached-notes header must stay a static-text element in \(appearance)")++ let workRow = app.buttons["Open Work Integration Work from integration.test"]+ assertContentControl(workRow, named: "Work row in \(appearance)")+ workRow.tap()++ // Work detail's §6 shape: the three rating-pulse cards.+ for identifier in [+ "work-detail-pulse-notes", "work-detail-pulse-up", "work-detail-pulse-down",+ ] {+ // `.any`, because a pulse card combines its count and its label into+ // one element — which XCUI reports as an "other", not a static text.+ let card = app.descendants(matching: .any).matching(identifier: identifier).firstMatch+ scrollToElement(card)+ XCTAssertTrue(+ card.waitForExistence(timeout: 10),+ "\(identifier) missing in \(appearance)")+ }++ let menu = app.buttons["work-detail-menu"]+ scrollToElement(menu)+ assertSystemControl(menu, named: "Work actions menu in \(appearance)")+ }+ private func openSeededEntry() { let chapter = app.buttons["Open entry Integration Work :: Integration Chapter from integration.test"] XCTAssertTrue(chapter.waitForExistence(timeout: 30))
diff --git a/Asterism/AsterismUITests/ComposedSurfaceUITests.swift b/Asterism/AsterismUITests/ComposedSurfaceUITests.swiftindex a9080a8..79cb5b8 100644--- a/Asterism/AsterismUITests/ComposedSurfaceUITests.swift+++ b/Asterism/AsterismUITests/ComposedSurfaceUITests.swift@@ -361,9 +361,18 @@ final class ComposedSurfaceUITests: XCTestCase { require(work, "The seeded taught Work should appear in Works", timeout: 20) work.tap() - // Work detail is a Form (lazy List); scroll the URL Identity section in.+ require(any("work-detail-pulse"), "Work detail opens on the seeded Work", timeout: 20)++ // Req 5.5 and Q20: §6's layout has no home for the URL-identity+ // machinery, so it moved behind the toolbar menu's Edit details sheet.+ // `dialogButton` because SwiftUI publishes a menu's buttons twice.+ require(app.dialogButton("work-detail-menu"), "Work detail carries the actions menu").tap()+ require(app.dialogButton("work-detail-edit-details-button"), "The menu opens").tap()+ require(any("work-detail-edit-details"), "Edit details is the route to URL identity")++ // Edit details is a Form (lazy); scroll the URL Identity section in. scrollAndTap(app.buttons["work-detail-review-url-identity"],- "Work detail presents Review URL identity for a Work with URL identity")+ "Edit details presents Review URL identity for a Work with URL identity") scrollAndTap(app.buttons["review-recalculate-button"], "The review offers Recalculate from the split/conflict states")
diff --git a/Asterism/AsterismUITests/DuplicateResolutionUITests.swift b/Asterism/AsterismUITests/DuplicateResolutionUITests.swiftindex d80f2a5..0391e81 100644--- a/Asterism/AsterismUITests/DuplicateResolutionUITests.swift+++ b/Asterism/AsterismUITests/DuplicateResolutionUITests.swift@@ -54,6 +54,20 @@ final class DuplicateResolutionUITests: XCTestCase { } } + /// Opens Work detail's toolbar menu, which is where Req 5.5 put Merge — it+ /// is no longer a control in the screen body, so a scroll cannot reach it.+ /// `dialogButton` because SwiftUI publishes a menu's buttons twice, nested+ /// inside a container carrying the same identifier.+ private func openWorkActionsMenu(file: StaticString = #filePath, line: UInt = #line) {+ require(+ any("work-detail-pulse"), "Work detail is on screen", timeout: 20,+ file: file, line: line)+ require(+ app.dialogButton("work-detail-menu"), "Work detail carries the actions menu",+ file: file, line: line+ ).tap()+ }+ private func waitForDisappearance( _ element: XCUIElement, _ message: String, timeout: TimeInterval = 30, file: StaticString = #filePath, line: UInt = #line@@ -155,9 +169,10 @@ final class DuplicateResolutionUITests: XCTestCase { // And it is a route, not a label. elsewhere.tap()- let merge = app.buttons["work-detail-merge-button"]- scrollTo(merge)- require(merge, "Tapping it reaches the Merge flow the set needs")+ openWorkActionsMenu()+ require(+ app.dialogButton("work-detail-merge-button"),+ "Tapping it reaches the Merge flow the set needs") } // MARK: - Torn group: read-only detail and the disclosure delete (Req 2.8)@@ -255,9 +270,10 @@ final class DuplicateResolutionUITests: XCTestCase { XCTAssertEqual(pill.label, "Resolve duplicate copies of Twinned Serial") pill.tap()- let merge = app.buttons["work-detail-merge-button"]- scrollTo(merge)- require(merge, "The pill routes to the Merge flow rather than the resolution sheet")+ openWorkActionsMenu()+ require(+ app.dialogButton("work-detail-merge-button"),+ "The pill routes to the Merge flow rather than the resolution sheet") XCTAssertFalse( any("duplicate-resolution-sheet").exists, "Q30: a divergent Work set with no torn member is a Merge, not the sheet")
diff --git a/Asterism/AsterismUITests/SearchUITests.swift b/Asterism/AsterismUITests/SearchUITests.swiftnew file mode 100644index 0000000..e14a1c5--- /dev/null+++ b/Asterism/AsterismUITests/SearchUITests.swift@@ -0,0 +1,182 @@+import XCTest++/// Search on Recent and Works (Reqs 3, 4), driven from app launch.+///+/// The filters themselves are pure structs with their own unit tests; what only+/// a journey can prove is that the field is reachable, that typing into it+/// narrows the list the reader is looking at, and that a query with nothing+/// behind it produces the empty state rather than a blank list (3.4, 4.3).+///+/// `seeded-composed` is the fixture with a taught Work in it: two `id.test`+/// entries under the Work "Real Work", plus one untaught `composed.test` entry+/// captured as `TtH - Story - Real Title`.+final class SearchUITests: XCTestCase {+ let app = XCUIApplication()++ override func setUp() {+ continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait+ terminateAndWaitForExit(app)+ }++ override func tearDown() {+ terminateAndWaitForExit(app)+ }++ // MARK: - Helpers++ private func launch(_ scenario: String) {+ app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = scenario+ app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+ app.launch()+ }++ private var recentRows: XCUIElementQuery {+ app.elements(withIdentifierPrefix: "recent-entry-")+ }++ private var workRows: XCUIElementQuery {+ 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)+ }++ private func type(_ query: String, file: StaticString = #filePath, line: UInt = #line) {+ let field = searchField(file: file, line: line)+ if !field.isHittable { app.swipeDown() }+ field.tap()+ field.typeText(query)+ }++ /// Req 3.5's clear. The field keeps focus, so the next query can be typed+ /// straight after without re-tapping.+ private func clearQuery() {+ let field = app.searchFields.firstMatch+ guard field.exists else { return }+ let clear = field.buttons.firstMatch+ if clear.exists, clear.isHittable {+ clear.tap()+ } else {+ field.tap()+ }+ }++ // MARK: - Recent (Req 3)++ func testRecentSearchNarrowsRowsAndRestoresThemWhenCleared() {+ launch("seeded-composed")+ waitFor(app.collectionViews["recent-list"], "Recent publishes the seeded library", timeout: 60)+ XCTAssertEqual(recentRows.count, 3, "The fixture seeds three entries")++ // Matches the two taught entries on their Work title, and nothing else:+ // the untaught row's cleaned capture title is `TtH - Story - Real Title`.+ type("Real Work")+ let narrowed = expectation(+ for: NSPredicate(format: "count == 2"), evaluatedWith: recentRows)+ XCTAssertEqual(+ XCTWaiter().wait(for: [narrowed], timeout: 15), .completed,+ "Typing a work title narrows Recent to that work's entries")++ // Req 3.5: clearing restores the prior list rather than a fresh read.+ clearQuery()+ let restored = expectation(+ for: NSPredicate(format: "count == 3"), evaluatedWith: recentRows)+ XCTAssertEqual(+ XCTWaiter().wait(for: [restored], timeout: 15), .completed,+ "Clearing the query restores every row")+ }++ func testRecentSearchWithNoMatchesShowsItsOwnEmptyState() {+ launch("seeded-composed")+ waitFor(app.collectionViews["recent-list"], "Recent publishes the seeded library", timeout: 60)++ type("zzqqxx")+ waitFor(+ app.anyElement("recent-search-empty"),+ "Req 3.4: a query with nothing behind it gets a message, not a blank list")+ XCTAssertFalse(+ app.anyElement("recent-empty").exists,+ "The message describes the query, not an empty library")+ }++ /// Req 3.2 / Q6: search runs **after** the banner filter, so both conditions+ /// apply. The divergent fixture is the only shape with a banner to compose+ /// with — two entries on one conservative key with different notes, beside a+ /// healthy row the filter excludes.+ func testRecentSearchComposesWithTheDuplicateFilter() {+ launch("seeded-tolerated-divergentEntrySet")+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ XCTAssertEqual(recentRows.count, 3, "Both members of the set plus the healthy row")++ waitFor(app.buttons["duplicate-banner"], "Recent shows the duplicate banner").tap()+ let filtered = expectation(+ for: NSPredicate(format: "count == 2"), evaluatedWith: recentRows)+ XCTAssertEqual(+ XCTWaiter().wait(for: [filtered], timeout: 15), .completed,+ "The banner filters Recent to the set")++ // Note text is one of the searched fields (3.1), and only one member's+ // note says "this device".+ type("this device")+ let narrowed = expectation(+ for: NSPredicate(format: "count == 1"), evaluatedWith: recentRows)+ XCTAssertEqual(+ XCTWaiter().wait(for: [narrowed], timeout: 15), .completed,+ "The query narrows within the filtered set")+ XCTAssertEqual(+ app.buttons.matching(identifier: "resolve-duplicate-pill").count, 1,+ "The surviving row is still a member of the set")++ // The healthy row matches the query but not the filter: both conditions+ // hold, so the result is empty rather than the banner's filter lapsing.+ clearQuery()+ type("Healthy")+ waitFor(+ app.anyElement("recent-search-empty"),+ "A row the banner excluded is not resurrected by matching the query")+ XCTAssertEqual(recentRows.count, 0, "Nothing satisfies both conditions")+ }++ // MARK: - Works (Req 4)++ func testWorksSearchNarrowsAndHidesTheUnattachedGroup() {+ launch("seeded-composed")+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ waitFor(app.tabBars.buttons["Works"], "The Works tab is reachable").tap()+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+ waitFor(app.staticTexts["unattached-section-header"], "Unattached notes start visible")++ type("Real Work")+ waitFor(app.collectionViews["works-list"], "The matching work is still listed")+ XCTAssertEqual(workRows.count, 1, "The seeded work matches its own title")+ // Req 4.2 / Q12: unattached entries have no work title to match, so the+ // group is hidden outright rather than shown empty.+ waitUntilGone(+ app.staticTexts["unattached-section-header"],+ "An active query hides the unattached group")+ }++ func testWorksSearchWithNoMatchesShowsItsOwnEmptyState() {+ launch("seeded-composed")+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ waitFor(app.tabBars.buttons["Works"], "The Works tab is reachable").tap()+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")++ type("zzqqxx")+ waitFor(+ app.anyElement("works-search-empty"),+ "Req 4.3: a query with no matching work gets a message")+ XCTAssertEqual(workRows.count, 0, "Nothing is listed behind the message")+ }+}
diff --git a/Asterism/AsterismUITests/SitesSettingsUITests.swift b/Asterism/AsterismUITests/SitesSettingsUITests.swiftnew file mode 100644index 0000000..c8bc204--- /dev/null+++ b/Asterism/AsterismUITests/SitesSettingsUITests.swift@@ -0,0 +1,112 @@+import XCTest++/// The Sites settings screen (Req 6), driven from app launch.+///+/// Two things only a journey can prove here. The re-teach route has to cross a+/// sheet dismissal — the composed surface is presented from `ContentView`, which+/// is covered while Settings is up, so the hostname is parked and the sheet+/// opens only after Settings has actually gone (`pendingReteachHostname`). And+/// the articles switch is a confirmation over a projection, which is the shape+/// that has silently done nothing before.+///+/// `seeded-composed` carries both site modes the screen distinguishes: an+/// untaught `composed.test` and a taught `id.test`.+final class SitesSettingsUITests: XCTestCase {+ let app = XCUIApplication()++ override func setUp() {+ continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait+ terminateAndWaitForExit(app)+ app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-composed"+ app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+ app.launch()+ }++ override func tearDown() {+ terminateAndWaitForExit(app)+ }++ // MARK: - Navigation helpers++ private func openSitesList() {+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ waitFor(app.buttons["settings-button"], "Recent carries the Settings route").tap()+ waitFor(app.anyElement("settings-view"), "Settings opens")+ scrollUntilTappableAndTap(+ app.buttons["settings-sites-button"], in: app,+ "Settings carries the route to the Sites list")+ waitFor(app.anyElement("settings-sites-list"), "The Sites list opens")+ }++ private func openSiteDetail(_ hostname: String) {+ scrollUntilTappableAndTap(+ app.buttons["site-row-\(hostname)"], in: app,+ "The list carries a row for \(hostname)")+ waitFor(app.anyElement("site-detail-view"), "The site's screen opens")+ }++ // MARK: - The list (Req 6.1)++ func testSettingsRoutesToASitesListCarryingEverySite() {+ openSitesList()+ waitFor(app.buttons["site-row-composed.test"], "The untaught site is listed")+ waitFor(app.buttons["site-row-id.test"], "The taught site is listed too")+ XCTAssertFalse(+ app.anyElement("settings-sites-empty").exists,+ "A library with sites does not show the empty message")+ }++ // MARK: - Re-teach across the Settings dismissal (Req 6.2)++ func testReteachingASiteOpensTheComposedSurfaceOnceSettingsHasGone() {+ openSitesList()+ openSiteDetail("id.test")++ XCTAssertFalse(+ app.anyElement("site-detail-reteach-unavailable").exists,+ "A site with notes has something to teach against")+ scrollUntilTappableAndTap(+ app.buttons["site-detail-reteach-button"], in: app,+ "The site offers the re-teach route")++ waitUntilGone(app.anyElement("settings-sites-list"), "Settings closes first")+ waitFor(+ app.buttons["composed-title-chip-0"],+ "The parked route opens the composed teaching surface")+ }++ // MARK: - Articles switch (Req 6.3)++ func testTheArticlesSwitchConfirmsBeforeItWrites() {+ openSitesList()+ openSiteDetail("composed.test")++ scrollUntilTappableAndTap(+ app.buttons["site-detail-articles-button"], in: app,+ "A non-articles site offers the switch")+ waitFor(+ app.dialogButton("site-detail-articles-confirm"),+ "The switch states its consequences before writing")+ declineConfirmationDialog(+ cancel: "site-detail-articles-cancel", dismissing: "site-detail-articles-confirm",+ in: app)++ waitFor(app.anyElement("site-detail-view"), "Declining leaves the site as it was")+ XCTAssertFalse(+ app.anyElement("site-detail-error").exists, "Declining is not a failure")++ // Confirming commits and the screen leaves: everything on it describes+ // the site as it was.+ scrollUntilTappableAndTap(+ app.buttons["site-detail-articles-button"], in: app, "The switch can be reopened")+ waitFor(app.dialogButton("site-detail-articles-confirm"), "The confirmation returns").tap()+ // A committed switch leaves the screen — everything on it describes the+ // site as it was. Staying put with no error is the shape the confirm+ // took before the contract moved onto the prompt: silently nothing.+ waitFor(+ app.anyElement("settings-sites-list"), "A committed switch returns to the list")+ XCTAssertFalse(+ app.anyElement("site-detail-error").exists, "The switch reported no failure")+ }+}
diff --git a/Asterism/AsterismUITests/UIJourneySupport.swift b/Asterism/AsterismUITests/UIJourneySupport.swiftnew file mode 100644index 0000000..d2e9d1f--- /dev/null+++ b/Asterism/AsterismUITests/UIJourneySupport.swift@@ -0,0 +1,97 @@+import XCTest++/// Shared element helpers for the M5 journey suites.+///+/// The older suites each carry a private copy of these; three new suites is the+/// point where copying them a fourth, fifth and sixth time stops being the house+/// pattern and starts being duplication. Deliberately named apart from those+/// private copies (`require`/`any`/`scrollAndTap`) so nothing in the existing+/// suites changes meaning.+extension XCUIApplication {+ /// Matches an identifier across element types. SwiftUI surfaces the same+ /// identifier as different element kinds depending on how it composes a row,+ /// so an identifier-only query has to be type-agnostic.+ func anyElement(_ identifier: String) -> XCUIElement {+ descendants(matching: .any).matching(identifier: identifier).firstMatch+ }++ func elements(withIdentifierPrefix prefix: String) -> XCUIElementQuery {+ buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", prefix))+ }++ /// A button inside a presented confirmation dialog or alert.+ ///+ /// SwiftUI publishes these twice — the identified button nested inside a+ /// container button carrying the same identifier — so the unqualified+ /// subscript is ambiguous and fails the tap with "Multiple matching+ /// elements found".+ func dialogButton(_ identifier: String) -> XCUIElement {+ buttons.matching(identifier: identifier).firstMatch+ }+}++extension XCTestCase {+ @discardableResult+ func waitFor(+ _ element: XCUIElement, _ message: String, timeout: TimeInterval = 30,+ file: StaticString = #filePath, line: UInt = #line+ ) -> XCUIElement {+ XCTAssertTrue(element.waitForExistence(timeout: timeout), message, file: file, line: line)+ return element+ }++ func waitUntilGone(+ _ element: XCUIElement, _ message: String, timeout: TimeInterval = 30,+ file: StaticString = #filePath, line: UInt = #line+ ) {+ let gone = expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: element)+ XCTAssertEqual(+ XCTWaiter().wait(for: [gone], timeout: timeout), .completed, message,+ file: file, line: line)+ }++ /// Declines a presented confirmation dialog.+ ///+ /// iOS renders these as an anchored popover here, and a popover-presented+ /// dialog **omits** the cancel button the code declares — a tap outside is+ /// the platform's own cancel. The identified control is still preferred+ /// wherever the platform draws one, so this keeps working if the+ /// presentation reverts to a bottom sheet.+ /// `dismissing` names a control the dialog always draws, so the closure is+ /// asserted rather than assumed.+ func declineConfirmationDialog(+ cancel cancelIdentifier: String, dismissing witnessIdentifier: String,+ in app: XCUIApplication, file: StaticString = #filePath, line: UInt = #line+ ) {+ let cancel = app.dialogButton(cancelIdentifier)+ if cancel.exists, cancel.isHittable {+ cancel.tap()+ } else {+ // Mid-screen, below the popover and above the tab bar; the dimming+ // layer absorbs the tap rather than passing it through.+ app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.55)).tap()+ }+ waitUntilGone(+ app.dialogButton(witnessIdentifier), "The declined dialog closes", timeout: 10,+ file: file, line: line)+ }++ /// 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.+ func scrollUntilTappableAndTap(+ _ element: XCUIElement, in app: XCUIApplication, _ message: String,+ file: StaticString = #filePath, line: UInt = #line+ ) {+ _ = element.waitForExistence(timeout: 15)+ for _ in 0..<8 {+ if element.exists, element.isHittable {+ element.tap()+ return+ }+ app.swipeUp()+ _ = element.waitForExistence(timeout: 2)+ }+ XCTFail(message, file: file, line: line)+ }+}
diff --git a/Asterism/AsterismUITests/WorkDetailActionsUITests.swift b/Asterism/AsterismUITests/WorkDetailActionsUITests.swiftnew file mode 100644index 0000000..4eae450--- /dev/null+++ b/Asterism/AsterismUITests/WorkDetailActionsUITests.swift@@ -0,0 +1,163 @@+import XCTest++/// The Work-detail toolbar menu and what hangs off it (Reqs 5.5, 7.1, 7.4),+/// driven from app launch.+///+/// `seeded-composed` gives the one shape these need: a taught Work ("Real Work"+/// on `id.test`) holding two entries, beside an unattached `composed.test`+/// capture. Deleting the Work therefore has something to delete or detach, and+/// the Works tab has a pre-existing unattached row to tell a detached note from.+final class WorkDetailActionsUITests: XCTestCase {+ let app = XCUIApplication()++ override func setUp() {+ continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait+ terminateAndWaitForExit(app)+ app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-composed"+ app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+ app.launch()+ }++ override func tearDown() {+ terminateAndWaitForExit(app)+ }++ // MARK: - Navigation helpers++ private var workRows: XCUIElementQuery {+ app.elements(withIdentifierPrefix: "work-row-")+ }++ private var unattachedRows: XCUIElementQuery {+ app.elements(withIdentifierPrefix: "unattached-entry-")+ }++ private func openWorksTab() {+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ waitFor(app.tabBars.buttons["Works"], "The Works tab is reachable").tap()+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+ }++ private func openWorkDetail() {+ openWorksTab()+ waitFor(workRows.firstMatch, "The seeded work is listed").tap()+ waitFor(app.anyElement("work-detail-pulse"), "Work detail presents the rating pulse")+ }++ private func openActionsMenu() {+ waitFor(app.dialogButton("work-detail-menu"), "Work detail carries the actions menu").tap()+ waitFor(app.dialogButton("work-detail-delete-button"), "The menu opens")+ }++ // MARK: - The menu (Req 5.5)++ func testTheToolbarMenuCarriesTheFourActionsAndOwnsTheMergeRoute() {+ openWorkDetail()++ // Req 5.5: Merge is reachable only from the menu, so it must not also be+ // sitting on the screen behind it.+ XCTAssertFalse(+ app.buttons["work-detail-merge-button"].exists,+ "Merge left the screen body when the menu took it")++ openActionsMenu()+ for identifier in [+ "work-detail-export-button", "work-detail-merge-button",+ "work-detail-edit-details-button", "work-detail-delete-button",+ ] {+ waitFor(app.buttons[identifier], "The menu offers \(identifier)", timeout: 10)+ }+ }++ // MARK: - Export (Req 2.4)++ func testExportFromTheMenuReachesTheShareSheet() {+ openWorkDetail()+ openActionsMenu()+ app.dialogButton("work-detail-export-button").tap()++ // The staged file is handed to `UIActivityViewController`; what the test+ // can assert without depending on the system sheet's own layout is that+ // a sheet came up over the screen and that the export did not fail.+ let sheetAppeared = app.anyElement("work-detail-export-share-sheet").waitForExistence(timeout: 30)+ || app.otherElements["ActivityListView"].waitForExistence(timeout: 5)+ || app.sheets.firstMatch.waitForExistence(timeout: 5)+ XCTAssertTrue(sheetAppeared, "Exporting a work presents the share sheet")+ XCTAssertFalse(+ app.anyElement("work-detail-error").exists,+ "The export reported no failure")+ }++ // MARK: - Deletion (Req 7.1, 7.4)++ func testTheDeleteDialogOffersBothDispositionsAndCancels() {+ openWorkDetail()+ openActionsMenu()+ app.dialogButton("work-detail-delete-button").tap()++ waitFor(app.dialogButton("work-detail-delete-entries"), "Req 7.1: delete its entries too")+ waitFor(app.dialogButton("work-detail-delete-detach"), "Req 7.1: or detach them")+ declineConfirmationDialog(+ cancel: "work-detail-delete-cancel", dismissing: "work-detail-delete-detach",+ in: app)++ // Cancelling writes nothing and leaves the screen where it was.+ waitFor(app.anyElement("work-detail-pulse"), "The work is still on screen")+ XCTAssertFalse(+ app.collectionViews["works-list"].exists,+ "Cancelling does not dismiss the detail screen")+ }++ /// Req 7.3/7.4's disposition: the work goes, its notes stay — as unattached+ /// notes on the Works tab.+ func testDetachingLandsTheNotesInUnattachedNotes() {+ openWorkDetail()+ let unattachedBefore = 1 // the untaught `composed.test` capture++ openActionsMenu()+ app.dialogButton("work-detail-delete-button").tap()+ waitFor(+ app.dialogButton("work-detail-delete-detach"), "The detach disposition is offered"+ ).tap()++ waitFor(app.collectionViews["works-list"], "A committed deletion dismisses the screen")+ waitUntilGone(workRows.firstMatch, "The work is gone from the Works tab")++ let landed = expectation(+ for: NSPredicate(format: "count == %d", unattachedBefore + 2),+ evaluatedWith: unattachedRows)+ XCTAssertEqual(+ XCTWaiter().wait(for: [landed], timeout: 30), .completed,+ "Both detached notes appear under Unattached Notes")+ XCTAssertTrue(+ app.buttons.matching(+ NSPredicate(format: "label CONTAINS[c] 'Chapter 7'")).firstMatch.exists,+ "The detached notes are the work's own")+ }++ /// The other disposition: the work and its notes both go.+ func testDeletingTheNotesTooRemovesThemFromTheLibrary() {+ openWorkDetail()++ openActionsMenu()+ app.dialogButton("work-detail-delete-button").tap()+ waitFor(+ app.dialogButton("work-detail-delete-entries"), "The delete-too disposition is offered"+ ).tap()++ waitFor(app.collectionViews["works-list"], "A committed deletion dismisses the screen")+ waitUntilGone(workRows.firstMatch, "The work is gone from the Works tab")+ XCTAssertEqual(+ unattachedRows.count, 1,+ "The deleted notes did not become unattached notes")++ waitFor(app.tabBars.buttons["Recent"], "Back to Recent").tap()+ let remaining = expectation(+ for: NSPredicate(format: "count == 1"),+ evaluatedWith: app.elements(withIdentifierPrefix: "recent-entry-"))+ XCTAssertEqual(+ XCTWaiter().wait(for: [remaining], timeout: 30), .completed,+ "Only the untaught capture is left in Recent")+ }+}
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9b939de..4fd0f62 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -32,10 +32,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- The Constellation look, everywhere (M5 Visual pass phase, `specs/polish-and-export/`). The whole app now wears the style guide: the night-sky backdrop behind Recent and Works, rows as material cards with a specular edge, each site's derived glyph in place of hostname text, serif large titles that scale with Dynamic Type, and — because every colour now goes through adaptive tokens — a real light mode throughout. The work detail's rating pulse renders as three cards and "Open last noted chapter" becomes the screen's single gradient button; entry detail gets rating capsules and a quieter monospaced provenance disclosure; sheets, Settings, merge, re-parse, import, and the maintenance surfaces all take the same treatment. Reduce Transparency replaces every material with an opaque fill rather than layering on top of it. Teach-mode chips settle their colours per the style guide — chapters amber, works cyan — including the name previews beneath them, and everything that can fail or needs your attention speaks amber while confirmations speak cyan, in the app and the share extension alike. The phase also paid for itself twice over in bugs its new UI journeys caught before any reader could: deleting a work while keeping its notes as unattached silently failed on sites taught by URL (and the same hole in Move to… saved a library state the next launch would have flagged), and the Sites screen's switch-to-articles confirmation committed nothing at all — the same dismissal-before-action trap the work-delete dialog had, now fixed and regression-tested in all four of the app's confirmation dialogs. Thirteen new UI journeys cover search, export, work deletion, and the Sites screens end to end; the full simulator suite runs clean.+- Search, export, the new work detail, and a Sites screen (M5 App functional phase, `specs/polish-and-export/`). Recent and Works each gain a search field: on Recent it matches your notes, chapter titles, and work titles — case- and accent-insensitively — and composes with the duplicate banner's filter, so you can search within the records waiting on you; on Works it matches work titles. An entry's detail screen gains an Export action that stages the entry as a markdown file and hands it to the share sheet, named after the chapter; export stays available on records other actions refuse. The work detail screen is rebuilt to the design's shape — open-last-noted, the rating pulse, the notes, and the chapter list, with title, type, and tags editable in place and a menu holding Export, Merge, Edit details, and Delete. Deleting a work asks first, states exactly how many entries it takes, offers keeping them as unattached notes instead, and — where sync has left differing copies of an entry — shows you every copy before anything is deleted; a change arriving between the question and your answer re-asks with fresh numbers rather than acting on stale ones. (A review of this phase caught, fixed, and regression-tested the deletion buttons silently doing nothing when driven from the real dialog — the dialog's dismissal cleared the very state the buttons read; they now carry what they need.) Settings gains a Sites screen listing every site the library knows, one row per hostname with its derived glyph, showing what each site has been taught and offering re-teach — including the previously one-way exit from articles mode back to a taught site, which keeps deliberately-unattached entries unattached.+- Groundwork for markdown export, work deletion, and the visual refresh (M5 Core phase, `specs/polish-and-export/`). One thing is visible already: the share extension's capture sheet now draws on the system's sheet glass and adapts to light and dark — and to Reduce Transparency — instead of forcing its own hard-coded dark palette; its content, behaviour, and accessibility identifiers are unchanged. The rest is machinery for the screens the later phases build. A markdown renderer that turns an entry or a whole work into a document: titles are escaped so a bracket in a chapter name stays text, the note is carried verbatim, the work line links to the work's page only where you confirmed that URL, and export never refuses a library in a tolerated state — a record sync has split renders once, from the copy that holds your content. The reads behind the coming Sites screen and work detail screen. Work deletion as a projection/commit pair with the same honesty rules as merge: you are shown every entry it will take — including every differing copy of a duplicated one — before anything is deleted, a work on a hostname the library already tolerates stays deletable (and deleting the offending record now clears the diagnosis without a relaunch), and a duplicate-set row shared with a surviving twin is re-pointed rather than destroyed. A flagged-off conversion of an articles site to a taught one that keeps deliberately-unattached entries unattached — two validator realities, recorded as Q32/Q33 in the decision log, forced it to clear the site's junk rule and re-mark those entries the way Move does rather than the way the design first described. And ConstellationKit: the app's colours, type, and layout as one adaptive token set — the hard-coded dark palette is deleted outright, every migrated surface reads from tokens that answer for both appearances, serif type now scales with Dynamic Type, and a site's glyph derives its hue from its hostname so a site keeps one colour everywhere. - **iCloud sync is on** (M4b, `specs/cloudkit-mirroring/`). The library now mirrors to CloudKit in both configurations (Development and Personal, each to its own container), verified by a two-device runbook whose results live in `specs/cloudkit-mirroring/runbook-log.md` — including concurrent-teach conflict repair on arrival and a 5,000-record import converging across devices with store-level diff evidence. The share extension still captures locally and never mirrors; captures sync when the app is next opened (T-2052 tracks narrowing that gap). What shipped underneath, and improves the app even with sync unavailable: duplicate Site rows for one hostname consolidate additively in the background (rows are never deleted; rule versions renumber deterministically and citations follow), teaching a duplicated hostname commits to the deterministically chosen winner row instead of being refused, backup export produces a file for quarantined, unresolved, and duplicate-row libraries alike (three named refusals remain: duplicate application UUIDs, an unrepresentable raw value naming the record, and references still arriving), and import is a modification-guarded chunked upsert onto the live library — no re-bootstrap, an interruption leaves a legal library that reports the unfinished archive by name in Settings. On the sync side: a two-phase store open that only attaches a mirrored container after first-launch certification, a sync monitor classifying failures into five classes with a persisted status file, an iCloud section in Settings whose health line refuses "healthy" while quarantined or duplicate-UUID counts are nonzero, a Recent banner for the one actionable failure class, and an "Arriving from iCloud" empty state for a first sync. The shared bulk chunk constant is measured rather than provisional (stays 500 — chunking buys interruption boundaries, not speed; bands in `specs/cloudkit-mirroring/implementation.md`), and the identity lint covers the mirroring flag, so each configuration's flip is one linted pbxproj value. ### Changed +- The design document and style guide now say what shipped (M5 Documentation phase, `specs/polish-and-export/`): amber is documented as "actionable attention" rather than teaching-only, the standalone per-entry export's work line is specified with its omission cases, export's `firstCapturedAt` ordering is named where it was ambiguous, and the articles-mode exit is recorded in the M5 summary. - Backup export no longer refuses a library just because sync duplicated something in it (M4c phase 5, `specs/duplicate-reconciliation/`). Export used to refuse on *any* repeated record; it now projects a duplicated record into the archive as the one record it is, and refuses only where two copies genuinely disagree — naming how many, and pointing at Check Library, with a button to get there. If those copies are waiting on a duplicated work to be merged first, it says so. Once you have resolved the last disagreement the next export succeeds. Two copies of a taught rule export as one rule, keeping whichever copy the site actually teaches from. - Measured, then made faster (M4c phase 6). The first measurement found that duplicate detection had been added to paths that run constantly — a background pass at launch and after every edit went from costing nothing to about a third of a second, and building the Recent screen walked the library three times over instead of once. Both are fixed: the background pass is now skipped entirely on a library with nothing to reconcile (~2 ms), and Recent is back to the speed it was before this release. Saving a note no longer triggers a full-library duplicate check.
diff --git a/Packages/AsterismCore/Package.swift b/Packages/AsterismCore/Package.swiftindex f4e0c71..bf53e0a 100644--- a/Packages/AsterismCore/Package.swift+++ b/Packages/AsterismCore/Package.swift@@ -10,6 +10,7 @@ let package = Package( ], products: [ .library(name: "AsterismCore", targets: ["AsterismCore"]),+ .library(name: "ConstellationKit", targets: ["ConstellationKit"]), .library( name: "AsterismV1MigrationSupport", targets: ["AsterismV1MigrationSupport"]@@ -19,6 +20,11 @@ let package = Package( ], targets: [ .target(name: "AsterismCore"),+ // The Constellation design language: tokens and SwiftUI components.+ // Deliberately independent of AsterismCore — Core stays SwiftUI-free,+ // and the share extension can import this without importing the store+ // layer (Q30).+ .target(name: "ConstellationKit"), .target( name: "AsterismV1MigrationSupport", dependencies: ["AsterismCore"]@@ -36,6 +42,10 @@ let package = Package( dependencies: ["AsterismCore", "AsterismV1MigrationSupport"], resources: [.copy("Fixtures")] ),+ .testTarget(+ name: "ConstellationKitTests",+ dependencies: ["ConstellationKit"]+ ), ], swiftLanguageModes: [.v6] )
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swiftindex 8e8f3ea..794e8ab 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift@@ -596,14 +596,8 @@ public final class BackupV4Exporter: Sendable { let fileURL = stagingDirectory.appending( path: Self.generateFilename(exportedAt: metadata.exportedAt)) do {- try encoded.write(to: fileURL, options: [.atomic])- #if os(iOS)- try FileManager.default.setAttributes(- [.protectionKey: FileProtectionType.completeUnlessOpen],- ofItemAtPath: fileURL.path)- #endif+ try ExportStaging.write(encoded, to: fileURL) } catch {- try? FileManager.default.removeItem(at: fileURL) throw BackupV4ExportError.stagingFailed(reason: String(describing: error)) } return BackupExportResult(fileURL: fileURL)@@ -619,20 +613,13 @@ public final class BackupV4Exporter: Sendable { } /// Removes abandoned backup files older than 24 hours from the staging area.+ ///+ /// By filename prefix: this directory is not exclusively the backup+ /// exporter's, so a scavenge by directory alone would delete a neighbour's+ /// staged file. public func scavengeStaleFiles() {- guard let contents = try? FileManager.default.contentsOfDirectory(- at: stagingDirectory,- includingPropertiesForKeys: [.contentModificationDateKey],- options: .skipsHiddenFiles- ) else { return }-- let cutoff = Date().addingTimeInterval(-24 * 3600)- for url in contents {- guard url.lastPathComponent.hasPrefix("Asterism-backup-") else { continue }- guard let attributes = try? url.resourceValues(forKeys: [.contentModificationDateKey]),- let modificationDate = attributes.contentModificationDate,- modificationDate < cutoff else { continue }- try? FileManager.default.removeItem(at: url)+ ExportStaging.scavenge(directory: stagingDirectory) {+ $0.lastPathComponent.hasPrefix("Asterism-backup-") } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swiftindex a1c620b..cd1f633 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift@@ -131,15 +131,26 @@ public struct ComposedTeachingRequest: Sendable, Equatable { public let trimSuffix: String? public let urlDefinition: URLRuleDefinition? public let acknowledgeUnsettled: Bool+ /// Whether this teach may convert an articles Site back to taught — the one+ /// way out of articles mode (Req 6.3, Decision 3).+ ///+ /// Default false, and only the Sites-screen re-teach route sets it. The gate+ /// it lifts exists so an inbox-driven teaching flow cannot convert an+ /// articles Site by accident; a deliberate re-teach from the screen whose+ /// job is site management is exactly the signal the gate demands, and+ /// lifting it there and nowhere else keeps accidental conversion impossible.+ public let permitsArticlesConversion: Bool public init(titleDefinition: PatternDefinition, trimPrefix: String? = nil, trimSuffix: String? = nil, urlDefinition: URLRuleDefinition? = nil,- acknowledgeUnsettled: Bool = false) {+ acknowledgeUnsettled: Bool = false,+ permitsArticlesConversion: Bool = false) { self.titleDefinition = titleDefinition self.trimPrefix = trimPrefix self.trimSuffix = trimSuffix self.urlDefinition = urlDefinition self.acknowledgeUnsettled = acknowledgeUnsettled+ self.permitsArticlesConversion = permitsArticlesConversion } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ExportStaging.swift b/Packages/AsterismCore/Sources/AsterismCore/ExportStaging.swiftnew file mode 100644index 0000000..6aded8c--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/ExportStaging.swift@@ -0,0 +1,64 @@+import Foundation++/// Staging a file for the share sheet, spelled once.+///+/// Two exporters hand the reader a file through `UIActivityViewController` — the+/// V4 backup and the markdown export — and both have to write it the same way:+/// atomically, protected so the share sheet can still read it while the device+/// is locked, and with no half-written file left behind if the write fails.+/// Both then have to clear whatever an abandoned share left in the directory.+/// Getting the protection class or the cutoff wrong in one of two copies is not+/// a difference either exporter would notice, so there is one copy.+///+/// Foundation only, and deliberately unopinionated about errors: each caller+/// wraps what this throws in its own vocabulary.+public enum ExportStaging {+ /// The age at which an abandoned staged file is rubbish.+ public static let staleAge: TimeInterval = 24 * 3600++ /// Writes `data` to `fileURL` atomically and protects it, removing the file+ /// again if either step fails so a failed export leaves nothing to share.+ ///+ /// The directory is the caller's business: both exporters create it in a+ /// scope where a failure to do so is a distinct error from a failure to+ /// write.+ public static func write(_ data: Data, to fileURL: URL) throws {+ do {+ try data.write(to: fileURL, options: [.atomic])+ #if os(iOS)+ try FileManager.default.setAttributes(+ [.protectionKey: FileProtectionType.completeUnlessOpen],+ ofItemAtPath: fileURL.path)+ #endif+ } catch {+ try? FileManager.default.removeItem(at: fileURL)+ throw error+ }+ }++ /// Removes files in `directory` that `matching` accepts and that have not+ /// been modified since `maxAge` ago.+ ///+ /// A missing or unreadable directory is nothing to clear, not a failure:+ /// scavenging runs alongside the reader's actual request and must never be+ /// what makes it fail.+ public static func scavenge(+ directory: URL,+ olderThan maxAge: TimeInterval = staleAge,+ matching isStageable: (URL) -> Bool+ ) {+ guard let contents = try? FileManager.default.contentsOfDirectory(+ at: directory,+ includingPropertiesForKeys: [.contentModificationDateKey],+ options: .skipsHiddenFiles+ ) else { return }++ let cutoff = Date().addingTimeInterval(-maxAge)+ for url in contents where isStageable(url) {+ guard let attributes = try? url.resourceValues(forKeys: [.contentModificationDateKey]),+ let modified = attributes.contentModificationDate,+ modified < cutoff else { continue }+ try? FileManager.default.removeItem(at: url)+ }+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swiftindex a0e98e6..31b63e1 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift@@ -14,6 +14,24 @@ public protocol LibraryProviding: Sendable { /// available actions, and unresolved replay. Built in one locked context. func entryTeachingDetail(id: UUID) async throws -> EntryTeachingDetail + /// Every stored site as one row per hostname (Req 6.1), for the Sites+ /// settings screen. Duplicate rows resolve to the winner rather than+ /// appearing twice (Q24) — row-level duplication is Library Check's subject.+ func sites() async throws -> [SiteSnapshot]++ /// Everything the §6 Work-detail screen presents: the work, its rating pulse+ /// over logical records, the newest note's URL, and the chapter-notes rows+ /// with cleaned display titles (Reqs 5.2–5.4).+ func workDetail(id: UUID) async throws -> WorkDetailPresentation++ // MARK: - Markdown export inputs (Reqs 1, 2)++ /// The `locale` parameter is the seam the golden tests pin: it is the one+ /// place a locale reaches the export, so the renderer stays a pure string+ /// function (Q17). Call the `.current` overloads in production.+ func entryExportInput(entryID: UUID, locale: Locale) async throws -> EntryExportInput+ func workExportInput(workID: UUID, locale: Locale) async throws -> WorkExportInput+ // MARK: - Diagnoses /// Everything the library currently knows to be incoherent (Req 4.2).@@ -133,6 +151,22 @@ public protocol LibraryProviding: Sendable { _ entryID: UUID, basis: EntryAssignmentBasis, to destination: WorkDestination ) async throws -> LibraryWriteOutcome + // MARK: - Work deletion (Req 7)++ /// What a work deletion would take: the basis, the entry groups it owns, and+ /// the torn ones among them for the disclosure (Req 7.2).+ func projectWorkDeletion(workID: UUID) async throws -> WorkDeletionContract++ /// `disclosedVariants` is what a torn group's alert showed the reader, and+ /// `nil` means no disclosure was made — refused on **both** dispositions,+ /// since detaching authors over an unseen variant as surely as deleting+ /// destroys it. Separate from the contract for `deleteEntry`'s reason: a+ /// passed-through projection proves nothing was shown to anybody.+ func commitWorkDeletion(+ _ contract: WorkDeletionContract, disposition: WorkDeletionDisposition,+ disclosedVariants: Set<VariantID>?+ ) async throws -> WorkDeletionCommitOutcome+ // MARK: - Teaching: preview → approve → commit /// Project an initial teaching operation. Returns a contract for approval.@@ -244,6 +278,15 @@ public protocol LibraryProviding: Sendable { public extension LibraryProviding { func shutdown() async {} + /// The production spelling of the export reads: the reader's own locale.+ func entryExportInput(entryID: UUID) async throws -> EntryExportInput {+ try await entryExportInput(entryID: entryID, locale: .current)+ }++ func workExportInput(workID: UUID) async throws -> WorkExportInput {+ try await workExportInput(workID: workID, locale: .current)+ }+ /// A conformer that only implements the old name still answers the new one. func recordCounts() async throws -> LibraryRecordCounts { try await debugCounts() } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swiftindex 426b22c..07fb877 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift@@ -50,6 +50,16 @@ extension LibraryRepository { } return try await withLockedContext(mode: .shared, operation: "projecting composed teaching") { context in let basis = try self.buildComposedTeachingBasis(hostname: hostname, context: context)+ // Decision 3: articles mode's only exit is a deliberate re-teach+ // from the Sites screen, which is the one route that sets the flag.+ // Every other caller keeps the default and is refused here rather+ // than at the commit — a projection the commit will always+ // invalidate is the dead end Req 3.4 exists to prevent.+ guard basis.siteMode != .articles || request.permitsArticlesConversion else {+ throw LibraryRepositoryError.invalidInput(+ operation: "projecting composed teaching",+ reason: "Site '\(hostname)' is in articles mode; teaching is unavailable")+ } let outcome = try ComposedTeachingProjectionPlanner.project(basis: basis, request: request) return ComposedTeachingContract(basis: basis, request: request, outcome: outcome) }@@ -99,9 +109,16 @@ extension LibraryRepository { // 4. Resolve rule identities (no-op detection reuses current versions). let sites = try Self.fetchSites(hostname: hostname, context: context) guard let site = sites.first else { return .invalidated(reason: "no Site for hostname '\(hostname)'") }- guard site.mode != .articles else {+ guard site.mode != .articles || contract.request.permitsArticlesConversion else { return .invalidated(reason: "articles Site cannot be composed-taught") }+ // The conversion out of articles mode (Decision 3). The `site.mode =+ // .taught` write below performs it; the junk-suffix rule goes with+ // the mode it belongs to, because the closed tuple table refuses a+ // non-articles Site that retains one — a retained "inert" rule+ // leaves a library the app refuses to open. Switching back to+ // articles authors a fresh rule anyway.+ let convertsFromArticles = site.mode == .articles let timestamp = self.clock.now() @@ -162,6 +179,7 @@ extension LibraryRepository { } site.mode = .taught+ if convertsFromArticles { site.junkSuffixRule = nil } // The composed model absorbs trims into the title rule; the V4 store // carries no Site-level interpretation or trim column (Req 3.1). @@ -174,6 +192,33 @@ extension LibraryRepository { titleTrimSuffix: Self.nonEmpty(contract.request.trimSuffix), url: resolvedURL.map { ($0.id, $0.version, $0.definition) }, timestamp: timestamp) + // 6b. The articles conversion's own fix-up (Decision 3).+ //+ // Articles mode marks its Entries intentionally unattached with+ // `.none` provenance, and the closed tuple table reads that pair as+ // legal *only while the Site is in articles mode*+ // (`V4LibraryValidator.validateAssignment`: a `.none` assignment+ // requires `intentionallyUnattached == (site.mode == .articles)`).+ // Leaving them alone would therefore make every one of them illegal+ // the moment the mode flips, and roll the conversion back.+ //+ // The mark itself must survive — §2.6, and Decision 3 says so in as+ // many words — so the provenance is promoted to `.manual` through+ // `applyManualAssignment`, the same write `moveEntry`'s "Leave+ // unattached" branch and the work-deletion detach both make (Q22:+ // one set of manual-assignment semantics, not three copies of a+ // field list). It also clears the URL assignment trio, which a+ // `.manual` row must not cite. Every row, because a logical+ // record's rows must not disagree about it (Req 2.7).+ if convertsFromArticles {+ let descriptor = FetchDescriptor<Entry>(+ predicate: #Predicate { $0.hostname == hostname })+ for row in try context.fetch(descriptor)+ where row.intentionallyUnattached && row.workAssignmentProvenance == .none {+ Self.applyManualAssignment(to: row, at: timestamp)+ }+ }+ // 7. Validate the complete prospective graph, then save once (Req 9.1). // The tolerant entry point: an unrelated hostname's tolerated state // must not roll back a teaching commit that is itself legal.
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swiftnew file mode 100644index 0000000..bba3a43--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift@@ -0,0 +1,167 @@+import Foundation+import SwiftData++// The repository half of markdown export (Q17): one shared-lock read per+// document, resolving identity groups, cleaned display titles and the+// locale-formatted date into the value types `MarkdownExport` renders.+//+// Everything that could refuse lives here rather than in the renderer, and+// nothing here refuses: a split or torn group exports the same deterministic+// carrier content the app presents (Reqs 1.8, 2.5), and a hostname with no Site+// row simply exports its capture title uncleaned.++extension LibraryRepository {++ /// One entry as a standalone export input, carrying its work line's fields+ /// (Req 1, Decision 4).+ public func entryExportInput(entryID: UUID, locale: Locale) async throws -> EntryExportInput {+ try await withLockedContext(+ mode: .shared, operation: "building entry export input"+ ) { context in+ // Normalised from the store for the same reason every other+ // single-record read is: this and Recent must agree about which+ // content the record presents (Req 3.2).+ let group = try Self.fetchNormalisedEntryGroup(id: entryID, context: context)+ var sites = SiteLookupCache()+ return try Self.exportInput(+ for: Self.snapshot(group), includingWork: true, sites: &sites,+ context: context, locale: locale)+ }+ }++ /// One work as an export input: its identity, its notes, and one block per+ /// logical record, oldest-first by `firstCapturedAt` (Req 2, Decision 2).+ public func workExportInput(workID: UUID, locale: Locale) async throws -> WorkExportInput {+ try await withLockedContext(+ mode: .shared, operation: "building work export input"+ ) { context in+ let group = try Self.fetchWorkGroup(id: workID, context: context)+ // The Entries under one Work group all point at rows of that group,+ // so they already agree about their assignment whatever the map says+ // (the `snapshot(WorkGroup:)` note). Stated, not defaulted.+ let work = try Self.snapshot(group, canonicalWorkIDs: [:])+ var sites = SiteLookupCache()+ // Decision 2's order is the Definitions one — earliest+ // `firstCapturedAt`, lowercased UUID as tie-break — which+ // `GroupOrdering` owns. Mapped through `SurvivorCandidate` as the+ // redirect's `inSurvivorOrder` does, so the export and the+ // reconciler cannot drift on what "oldest first" means.+ let entriesByID = Dictionary(+ work.entries.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })+ let ordered = GroupOrdering.sortedSurvivorCandidates(+ work.entries.map { SurvivorCandidate(id: $0.id, timestamp: $0.firstCapturedAt) }+ ).compactMap { entriesByID[$0.id] }+ let blocks = try ordered.map {+ // The H1 names the work, so an embedded block carries no work+ // line (Req 1.9) — expressed by the input it is built with, and+ // again by the renderer, which never emits one here.+ try Self.exportInput(+ for: $0, includingWork: false, sites: &sites, context: context, locale: locale)+ }+ return WorkExportInput(+ titleText: work.displayTitle,+ siteName: try Self.siteDisplayName(+ hostname: work.siteHostname, sites: &sites, context: context),+ workURLString: work.workURLString,+ genericNotes: work.genericNotes,+ blocks: blocks)+ }+ }++ // MARK: - Shared derivation++ /// The Site row each hostname presents through, resolved once per read.+ ///+ /// A per-hostname winner lookup, not a per-record one: presentation follows+ /// the hostname winner (Decision 5 of `specs/relational-references`), and a+ /// work's entries are nearly always all on one hostname.+ internal struct SiteLookupCache {+ private var rows: [String: Site?] = [:]++ mutating func site(+ for hostname: String, context: ModelContext+ ) throws -> Site? {+ if let cached = rows[hostname] { return cached }+ let site = try LibraryRepository.fetchSites(hostname: hostname, context: context).first+ rows[hostname] = site+ return site+ }+ }++ internal static func exportInput(+ for entry: EntrySnapshot,+ includingWork: Bool,+ sites: inout SiteLookupCache,+ context: ModelContext,+ locale: Locale+ ) throws -> EntryExportInput {+ // The chapter title where there is one, the cleaned capture title+ // otherwise (Req 1.2) — the same `presentationTitle` path Recent and+ // Entry detail display through.+ let heading = try entry.chapterTitle+ ?? displayTitle(+ for: entry.captureTitle, hostname: entry.hostname, sites: &sites, context: context)++ var workTitle: String?+ var workTypeLabel: String?+ if includingWork, let workID = entry.workID {+ // The one tolerated case is an assignment naming a Work with no rows+ // behind it: the work line is simply omitted, the way a hostname with+ // no Site row exports its title uncleaned. Every other failure is the+ // store not answering, and swallowing it would hand back a block+ // silently missing the line it should have carried.+ let group: WorkGroup?+ do { group = try fetchWorkGroup(id: workID, context: context) }+ catch let error as LibraryRepositoryError {+ guard case .recordNotFound = error else { throw error }+ group = nil+ }+ if let group {+ // The group's carrier content, so a split work presents the same+ // title in the export as on its own screen (Req 1.9).+ let work = try snapshot(group, canonicalWorkIDs: [:])+ workTitle = work.displayTitle+ // `.other` is the untyped default, and Decision 4 omits it.+ workTypeLabel = work.type == .other ? nil : work.type.rawValue+ }+ }++ return EntryExportInput(+ headingText: heading,+ rawURLString: entry.rawURLString,+ workTitle: workTitle,+ workTypeLabel: workTypeLabel,+ dateText: exportDateText(entry.firstCapturedAt, locale: locale),+ rating: entry.rating,+ note: entry.note)+ }++ /// The title a surface shows for a capture title on this hostname — the+ /// display-path wrapper Recent and Entry detail already go through. Shared+ /// with the Work-detail read, which must not clean titles a second way.+ internal static func displayTitle(+ for captureTitle: String, hostname: String, sites: inout SiteLookupCache,+ context: ModelContext+ ) throws -> String {+ // With no Site row — or a mode this app does not define — there is no+ // cleaning to apply, and the immutable capture title is the+ // presentation title. Export never refuses over either.+ guard let site = try sites.site(for: hostname, context: context),+ let mode = SiteMode(rawValue: site.modeRaw)+ else { return captureTitle }+ return presentationTitle(for: captureTitle, siteMode: mode, site: site)+ }++ private static func siteDisplayName(+ hostname: String, sites: inout SiteLookupCache, context: ModelContext+ ) throws -> String {+ siteDisplayName(try sites.site(for: hostname, context: context), hostname: hostname)+ }++ /// The date line's text (Req 1.3, Q8): `firstCapturedAt` in the given+ /// locale's long style. The one place the export touches a locale, which is+ /// what keeps the renderer pure and its goldens stable.+ internal static func exportDateText(_ date: Date, locale: Locale) -> String {+ date.formatted(Date.FormatStyle(date: .long, time: .omitted).locale(locale))+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swiftindex 6100f91..f1e159a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift@@ -157,13 +157,41 @@ extension LibraryRepository { internal func resolveWorkWriteTarget( id: UUID, basis: WorkEditBasis, context: ModelContext ) throws -> ResolvedWriteTarget<WorkGroup> {+ switch try redirectWork(id: id, basis: basis, context: context) {+ case .refused(let conflict): return .refused(conflict)+ case .resolved(let group?): return .resolved(group)+ case .resolved(nil):+ throw LibraryRepositoryError.recordNotFound(type: "Work", id: id)+ }+ }++ /// The deletion counterpart, with `resolveEntryDeletionTarget`'s rule: a+ /// record with no identity match at all is genuinely gone, and `nil` is+ /// success rather than an error.+ ///+ /// `resolveWorkWriteTarget` cannot stand in for this. It throws+ /// `recordNotFound` where there is nothing to resolve to, which is the right+ /// answer for an edit and the wrong one for a deletion — the reader's tap+ /// would surface as a failure for a work that is already gone. The redirect+ /// itself is the same for both, so both take it from `redirectWork` and+ /// differ only in what they make of an empty answer.+ internal func resolveWorkDeletionTarget(+ id: UUID, basis: WorkEditBasis, context: ModelContext+ ) throws -> ResolvedWriteTarget<WorkGroup?> {+ try redirectWork(id: id, basis: basis, context: context)+ }++ /// `redirectEntry`'s counterpart on the Work side: the addressed row if it+ /// is still there, otherwise the survivor that agrees with the basis, a+ /// refusal if none does, and `nil` where the set has no member at all.+ private func redirectWork(+ id: UUID, basis: WorkEditBasis, context: ModelContext+ ) throws -> ResolvedWriteTarget<WorkGroup?> { if let group = try? Self.fetchWorkGroup(id: id, context: context) { return .resolved(group) } let candidates = try workSurvivorCandidates(id: id, basis: basis, context: context)- guard let fallback = candidates.first else {- throw LibraryRepositoryError.recordNotFound(type: "Work", id: id)- }+ guard let fallback = candidates.first else { return .resolved(nil) } if let redirected = candidates.first(where: { !$0.isTorn && (basis.matches($0.presentedContent) || $0.presentedContent.isBare) }) {
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swiftnew file mode 100644index 0000000..4655835--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift@@ -0,0 +1,67 @@+import Foundation+import SwiftData++// The Sites settings screen's read (Req 6.1).+//+// Every stored site, taught or not (Q10) — the untaught ones are exactly the+// ones the screen exists to send the reader to teach. One row per hostname+// (Q24): the screen shows teaching knowledge, and duplicate *rows* are Library+// Check's subject, not this screen's.++extension LibraryRepository {++ public func sites() async throws -> [SiteSnapshot] {+ try await withLockedContext(mode: .shared, operation: "reading Sites") { context in+ let rows = try context.fetch(FetchDescriptor<Site>())+ // The same winner rule presentation uses everywhere else+ // (`SiteResolutionOrder`), so this screen names the row a teach from+ // it would write to.+ return SiteResolutionOrder.winnersByHostname(rows)+ .values+ .compactMap(Self.siteSnapshot)+ .sorted { left, right in+ let byName = left.displayName.localizedStandardCompare(right.displayName)+ if byName != .orderedSame { return byName == .orderedAscending }+ return left.hostname < right.hostname+ }+ }+ }++ /// A site row as the screen sees it, or nil where this app cannot describe+ /// it.+ ///+ /// A mode raw value outside the closed set has no honest row: every action+ /// the screen offers is mode-dependent, and coercing it to `.untaught` would+ /// offer teaching for a state nothing here understands. Such a row is a+ /// per-Site diagnosis, which Library Check reports — this screen omits it+ /// rather than mis-describing it.+ private static func siteSnapshot(_ site: Site) -> SiteSnapshot? {+ guard let mode = SiteMode(rawValue: site.modeRaw) else { return nil }+ let displayName = siteDisplayName(site, hostname: site.hostname)+ return SiteSnapshot(+ hostname: site.hostname,+ displayName: displayName,+ mode: mode,+ patternIDs: site.patternValues+ .sorted {+ if $0.version != $1.version { return $0.version < $1.version }+ return $0.id.uuidString < $1.id.uuidString+ }+ .map(\.id),+ urlIdentityRule: site.urlIdentityRule,+ junkSuffixRule: site.junkSuffixRule)+ }++ /// The name a Site presents under: its declared display name, or the+ /// hostname where that is blank or absent.+ ///+ /// Shared with markdown export, which names the same Site in its front+ /// matter. A site the Sites list calls by its hostname and the export calls+ /// by a blank string is the same site described two ways.+ internal static func siteDisplayName(_ site: Site?, hostname: String) -> String {+ guard let name = site?.displayName,+ !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty+ else { return hostname }+ return name+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swiftnew file mode 100644index 0000000..fde80c0--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift@@ -0,0 +1,274 @@+import Foundation+import OSLog+import SwiftData++// Work deletion (Req 7): a project/commit pair on `commitMerge`'s shape, with+// `deleteEntry`'s disclosure gate.+//+// The reader chooses what happens to the work's notes, and both choices are+// destructive in the sense Req 2.8 cares about: deleting destroys authored+// variants, and detaching authors over them — `intentionallyUnattached` is+// authored content. So the disclosure gate applies to both dispositions, not+// only to the delete one.++private let workDeletionLogger = Logger(+ subsystem: "AsterismCore", category: "LibraryRepository+WorkDeletion")++/// What happens to the work's entries (Req 7.1).+public enum WorkDeletionDisposition: Sendable, Equatable {+ case deleteEntries+ case detachEntries+}++/// What the reader confirmed against, and what the commit re-verifies.+public struct WorkDeletionContract: Sendable, Equatable {+ public let basis: WorkEditBasis+ public let workID: UUID+ /// The entry groups this work owns outright — what a commit deletes or+ /// detaches, and what the dialog's count names (Q28). Groups shared with a+ /// duplicate-set sibling are not here: they are repointed (Q25).+ public let entryGroupIDs: Set<UUID>+ /// The owned groups whose rows disagree about something the reader wrote,+ /// with the variants the disclosure has to show (Req 7.2).+ public let tornEntryVariants: [UUID: [AuthoredVariant<EntryAuthoredContent>]]++ public init(+ basis: WorkEditBasis, workID: UUID, entryGroupIDs: Set<UUID>,+ tornEntryVariants: [UUID: [AuthoredVariant<EntryAuthoredContent>]]+ ) {+ self.basis = basis+ self.workID = workID+ self.entryGroupIDs = entryGroupIDs+ self.tornEntryVariants = tornEntryVariants+ }++ /// Every variant a disclosure over this contract covers — what a caller that+ /// showed the whole alert passes back as `disclosedVariants`.+ internal var disclosableVariantIDs: Set<VariantID> {+ Set(tornEntryVariants.values.flatMap { $0.map(\.id) })+ }+}++/// Mirrors `WorkMergeCommitOutcome`, plus the conflict the disclosure gate+/// needs (Q29). No existing outcome type holds all four states.+public enum WorkDeletionCommitOutcome: Sendable, Equatable {+ case committed+ case refreshed(WorkDeletionContract)+ case conflict(WriteConflict)+ case invalidated(reason: String)+}++extension LibraryRepository {++ // MARK: - Projection++ public func projectWorkDeletion(workID: UUID) async throws -> WorkDeletionContract {+ try await withLockedContext(+ mode: .shared, operation: "projecting Work deletion"+ ) { context in+ let group = try Self.fetchWorkGroup(id: workID, context: context)+ return try Self.workDeletionContract(for: group, context: context).contract+ }+ }++ // MARK: - Commit++ /// Deletes the work group whole and applies `disposition` to the entry+ /// groups it owns, in one exclusive lock and one save (Reqs 7.2–7.6).+ ///+ /// `disclosedVariants` is what a torn group's alert showed the reader, and+ /// **`nil` says no disclosure was made** — the same rule, and the same+ /// reason, as `deleteEntry`'s: a caller that reads the variants off the+ /// record and hands them straight back has disclosed nothing to anybody.+ public func commitWorkDeletion(+ _ contract: WorkDeletionContract,+ disposition: WorkDeletionDisposition,+ disclosedVariants: Set<VariantID>?+ ) async throws -> WorkDeletionCommitOutcome {+ try await withLockedContext(mode: .exclusive, operation: "deleting Work") { context in+ // 1. Where the deletion lands. A record with no identity match at+ // all is genuinely gone, and a deletion that finds nothing to delete+ // has already achieved what it was asked for.+ let group: WorkGroup+ switch try self.resolveWorkDeletionTarget(+ id: contract.workID, basis: contract.basis, context: context) {+ case .refused(let conflict): return .conflict(conflict)+ case .resolved(let resolved):+ guard let resolved else { return .committed }+ group = resolved+ }++ // 2. A torn work has no single value for anything the dialog said,+ // and the screen is read-only until its resolution (Req 2.8). Q55:+ // tornness can arrive between the projection and the commit, so it+ // is re-derived here rather than trusted from the contract.+ if group.isTorn {+ return .conflict(.torn(recordID: group.id, variants: group.variants.map(\.id)))+ }++ // 3. Re-derive everything the dialog was built from. An entry+ // captured or synced in since the projection changes what the commit+ // would take, and the reader confirmed a count (Q28).+ let (fresh, owned, shared) = try Self.workDeletionContract(+ for: group, context: context)+ guard fresh.basis == contract.basis,+ fresh.entryGroupIDs == contract.entryGroupIDs+ else {+ workDeletionLogger.debug("Work deletion detected stale state; refreshing")+ return .refreshed(fresh)+ }++ // 5. The disclosure gate, on **both** dispositions.+ if let firstTorn = owned.first(where: \.isTorn) {+ let variants = firstTorn.variants.map(\.id)+ guard let disclosedVariants else {+ return .conflict(.torn(recordID: firstTorn.id, variants: variants))+ }+ let current = Set(owned.filter(\.isTorn).flatMap(\.variantIDs))+ guard disclosedVariants == current else {+ return .conflict(.disclosureStale(recordID: firstTorn.id, variants: variants))+ }+ }++ let hostname = group.representative.siteHostname+ // Read before the mutation: step 7 rolls back only for a diagnosis+ // this write *introduced*.+ let priorDiagnosis = self.quarantineReason(hostname: hostname)+ let timestamp = MillisecondInstant.quantize(self.clock.now())++ // 4. Shared groups are repointed, not deleted (Q25). Relying on+ // SwiftData's nullify would tear the group: a nil assignment never+ // normalises against the sibling's manual one, so the group would+ // read torn and go read-only. Pointer only, keeping note, rating,+ // provenance and timestamps — `DuplicateReconciler.repointEntries`'+ // rule, for the same reason it has it.+ for entryGroup in shared {+ guard let survivor = entryGroup.rows.compactMap(\.work)+ .first(where: { $0.id != group.id })+ else { continue }+ for row in entryGroup.rows where row.work?.id == group.id { row.work = survivor }+ }++ // 6. The disposition, applied to every row of every owned group+ // (Req 2.7).+ for entryGroup in owned {+ switch disposition {+ case .deleteEntries:+ for row in entryGroup.rows { context.delete(row) }+ case .detachEntries:+ // Exactly `moveEntry`'s "Leave unattached" block (Q22) —+ // literally, through the write both share — so §2.6's+ // re-parse guarantee holds by the tests that already cover+ // it: no later re-parse reattaches these.+ for row in entryGroup.rows {+ row.work = nil+ row.intentionallyUnattached = true+ LibraryRepository.applyManualAssignment(to: row, at: timestamp)+ }+ }+ }+ // The work group goes whole (Req 7.5): a proper subset left behind+ // is a work the reader deleted that is still in their library.+ for row in group.rows { context.delete(row) }++ // 7. Validate the prospective graph, scoped to the hostname the+ // deletion touched, then save once. Deletion changes the+ // relationship graph the way Merge does, so it takes Merge's+ // validator discipline (Q21) — but compared against the *prior*+ // diagnosis rather than against nil, or a hostname that was already+ // diagnosed would have undeletable works (Decision 8 of+ // `specs/title-teaching-retroactive-parsing`).+ let diagnoses: [String: V4ValidationError]+ do {+ diagnoses = try V4LibraryValidator.validate(+ hostnames: [hostname], context: context)+ } catch {+ context.rollback()+ workDeletionLogger.error(+ "Work deletion failed validation: \(String(describing: error), privacy: .public)"+ )+ return .invalidated(reason: "Work deletion could not be validated: \(error)")+ }+ if let diagnosis = diagnoses[hostname], diagnosis != priorDiagnosis {+ context.rollback()+ return .invalidated(+ reason: "Work deletion produced an invalid library state: \(diagnosis)")+ }++ do { try self.saveStrategy.save(context) }+ catch {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "atomically deleting Work",+ reason: String(describing: error))+ }+ // The commit's own validation is the fresher answer for this+ // hostname, exactly as it is for the teaching commits: a deletion+ // that removed the offending record repaired the diagnosis, and+ // leaving the map alone would keep quarantining a hostname that is+ // now legal. A diagnosis the commit left untouched is re-recorded+ // unchanged, so nothing the deletion did not repair is cleared.+ self.recordPostCommitDiagnosis(diagnoses[hostname], hostname: hostname)+ workDeletionLogger.debug("Deleted Work \(group.id.uuidString)")+ return .committed+ }+ }++ // MARK: - Contract derivation++ /// The contract for this work group, and the entry groups behind it.+ ///+ /// One derivation for the projection and the commit, so the set the dialog+ /// counted and the set the commit compares against cannot be two different+ /// questions.+ private static func workDeletionContract(+ for group: WorkGroup, context: ModelContext+ ) throws -> (contract: WorkDeletionContract, owned: [EntryGroup], shared: [EntryGroup]) {+ let snapshot = try snapshot(group, canonicalWorkIDs: [:])+ let (owned, shared) = try entryGroups(ofWorkGroup: group, context: context)+ var torn: [UUID: [AuthoredVariant<EntryAuthoredContent>]] = [:]+ for entryGroup in owned where entryGroup.isTorn {+ torn[entryGroup.id] = entryGroup.variants+ }+ let contract = WorkDeletionContract(+ basis: WorkEditBasis(work: snapshot),+ workID: group.id,+ entryGroupIDs: Set(owned.map(\.id)),+ tornEntryVariants: torn)+ return (contract, owned, shared)+ }++ /// The work's entries as logical records, split by whether this work owns+ /// them outright.+ ///+ /// A group is **shared** when any of its rows points at a different work —+ /// the duplicate-set-sibling state `canonicalWorkIDs` normalises, where two+ /// rows of one record point at two members of one Work set. Only wholly+ /// owned groups are deleted or detached; the rest are repointed (Q25).+ ///+ /// The candidate rows are fetched by application UUID rather than off the+ /// relationship, because a row of a split group can be attached elsewhere —+ /// which is exactly the case this split exists to find.+ private static func entryGroups(+ ofWorkGroup group: WorkGroup, context: ModelContext+ ) throws -> (owned: [EntryGroup], shared: [EntryGroup]) {+ let candidateIDs = Set(group.rows.flatMap { $0.entryValues }.map(\.id))+ guard !candidateIDs.isEmpty else { return ([], []) }+ let rows = try context.fetch(FetchDescriptor<Entry>())+ .filter { candidateIDs.contains($0.id) }+ let groups = entryGroups(+ rows, canonicalWorkIDs: try canonicalWorkIDs(normalising: rows, context: context))++ var owned: [EntryGroup] = []+ var shared: [EntryGroup] = []+ // Ordered by application UUID, so a refusal naming "the first torn+ // group" names the same one on every run.+ for entryGroup in groups.values.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {+ let spansAnotherWork = entryGroup.rows.contains { row in+ guard let workID = row.work?.id else { return false }+ return workID != group.id+ }+ if spansAnotherWork { shared.append(entryGroup) } else { owned.append(entryGroup) }+ }+ return (owned, shared)+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swiftnew file mode 100644index 0000000..438ffec--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift@@ -0,0 +1,103 @@+import Foundation+import SwiftData++// Everything the §6 Work-detail screen presents, in one shared-lock read+// (Reqs 5.1–5.4).+//+// The counts and the chapter titles are derived here rather than in the view for+// the same reason Recent's are: a count over *rows* would double a split group,+// and a title cleaned at the view would clean it differently from the row Recent+// shows for the same Entry (Req 3.2).++/// The three counts of Req 5.2 — over logical records, never rows.+public struct RatingPulse: Sendable, Equatable {+ public let notes: Int+ public let up: Int+ public let down: Int++ public init(notes: Int, up: Int, down: Int) {+ self.notes = notes+ self.up = up+ self.down = down+ }+}++/// One row of the chapter-notes list (Req 5.4).+public struct WorkChapterRow: Identifiable, Sendable, Equatable {+ public let id: UUID+ /// The chapter title where there is one, the cleaned capture title+ /// otherwise — the repository's `presentationTitle`, not the raw column.+ public let displayTitle: String+ public let note: String+ public let rating: Rating?+ public let lastSharedAt: Date++ public init(+ id: UUID, displayTitle: String, note: String, rating: Rating?, lastSharedAt: Date+ ) {+ self.id = id+ self.displayTitle = displayTitle+ self.note = note+ self.rating = rating+ self.lastSharedAt = lastSharedAt+ }+}++public struct WorkDetailPresentation: Sendable, Equatable {+ public let work: WorkSnapshot+ public let pulse: RatingPulse+ /// The raw URL of the entry with the newest `lastSharedAt`, or nil where the+ /// work has no entries — which is what hides the action (Req 5.3).+ public let lastNotedURLString: String?+ /// Newest-first by `lastSharedAt`, the app's own ordering (§7).+ public let chapterRows: [WorkChapterRow]++ public init(+ work: WorkSnapshot, pulse: RatingPulse, lastNotedURLString: String?,+ chapterRows: [WorkChapterRow]+ ) {+ self.work = work+ self.pulse = pulse+ self.lastNotedURLString = lastNotedURLString+ self.chapterRows = chapterRows+ }+}++extension LibraryRepository {++ public func workDetail(id: UUID) async throws -> WorkDetailPresentation {+ try await withLockedContext(mode: .shared, operation: "reading Work detail") { context in+ let group = try Self.fetchWorkGroup(id: id, context: context)+ // The Entries under one Work group all point at rows of that group,+ // so they already agree about their assignment whatever the map says+ // (the `snapshot(WorkGroup:)` note). Stated, not defaulted.+ let work = try Self.snapshot(group, canonicalWorkIDs: [:])+ // One snapshot per logical record already, in activity order —+ // newest `lastSharedAt` first, which is both the list's order (5.4)+ // and the open-last-noted answer (5.3).+ let entries = work.entries++ var sites = SiteLookupCache()+ let rows = try entries.map { entry in+ WorkChapterRow(+ id: entry.id,+ displayTitle: try entry.chapterTitle+ ?? Self.displayTitle(+ for: entry.captureTitle, hostname: entry.hostname, sites: &sites,+ context: context),+ note: entry.note,+ rating: entry.rating,+ lastSharedAt: entry.lastSharedAt)+ }++ return WorkDetailPresentation(+ work: work,+ pulse: RatingPulse(+ notes: entries.count,+ up: entries.filter { $0.rating == .up }.count,+ down: entries.filter { $0.rating == .down }.count),+ lastNotedURLString: entries.first?.rawURLString,+ chapterRows: rows)+ }+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex b077507..b53889c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -1322,12 +1322,7 @@ public actor LibraryRepository { } } - for row in group.rows {- row.workAssignmentProvenance = .manual- row.workPatternID = nil- row.workPatternVersion = nil- row.modifiedAt = timestamp- }+ for row in group.rows { Self.applyManualAssignment(to: row, at: timestamp) } do { try saveStrategy.save(context) } catch { throw LibraryRepositoryError.libraryUnavailable(@@ -1339,6 +1334,28 @@ public actor LibraryRepository { } } + /// The manual-assignment row write, shared by `moveEntry` and+ /// `commitWorkDeletion`'s detach disposition (Q22): one set of+ /// manual-assignment semantics in the codebase, not two copies of a list of+ /// fields.+ ///+ /// Clearing the **URL** assignment trio is not bookkeeping. The V4 tuple+ /// table refuses a `.manual` assignment that still cites a URL rule+ /// ("manual assignment has incompatible relationship or provenance"), and+ /// every Entry a URL-taught Site assigned cites one — so a detach or a move+ /// that left them behind produced an illegal Entry. `commitWorkDeletion`+ /// validates and so rolled the whole deletion back; `moveEntry` does not,+ /// and saved a graph the next open quarantines.+ internal static func applyManualAssignment(to row: Entry, at timestamp: Date) {+ row.workAssignmentProvenance = .manual+ row.workPatternID = nil+ row.workPatternVersion = nil+ row.workURLRuleID = nil+ row.workURLRuleVersion = nil+ row.workURLAssignmentKindRaw = nil+ row.modifiedAt = timestamp+ }+ /// Stamps every row of each named Work group, so a group's rows never differ /// in a field the representative ordering reads. private func stampWorkGroups(
diff --git a/Packages/AsterismCore/Sources/AsterismCore/MarkdownExport.swift b/Packages/AsterismCore/Sources/AsterismCore/MarkdownExport.swiftnew file mode 100644index 0000000..f2ce3b9--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/MarkdownExport.swift@@ -0,0 +1,246 @@+import Foundation++// The markdown export renderer (Reqs 1, 2) and the value types it consumes.+//+// Pure by construction (Q17): no I/O, no store access, and no locale access.+// The repository resolves identity groups, cleaned titles and the locale-long+// date string into the input structs below, so this file is a total string+// function whose every case is a golden test rather than a fixture run. It is+// also the **one** owner of trimming, newline collapsing, escaping and link+// encoding — a second speller of "escape a title" is how a document ends up+// half-escaped.++/// Everything one entry block needs, in raw (unescaped) form.+///+/// `headingText` is the cleaned display title (Req 1.2) exactly as the app+/// presents it: the renderer escapes it, so nothing upstream has to know what+/// markdown considers punctuation. `workTypeLabel` is nil where the type is+/// `.other` (Decision 4), and `workTitle` is nil for an unattached entry or for+/// a block that will be embedded in a per-work document.+public struct EntryExportInput: Sendable, Equatable {+ public let headingText: String+ public let rawURLString: String+ public let workTitle: String?+ public let workTypeLabel: String?+ /// The entry's `firstCapturedAt` in the reader's locale, long style (Q8,+ /// Decision 2). Pre-formatted so the renderer stays locale-free.+ public let dateText: String+ public let rating: Rating?+ public let note: String++ public init(+ headingText: String,+ rawURLString: String,+ workTitle: String? = nil,+ workTypeLabel: String? = nil,+ dateText: String,+ rating: Rating? = nil,+ note: String = ""+ ) {+ self.headingText = headingText+ self.rawURLString = rawURLString+ self.workTitle = workTitle+ self.workTypeLabel = workTypeLabel+ self.dateText = dateText+ self.rating = rating+ self.note = note+ }+}++/// Everything a per-work document needs (Req 2.1).+///+/// `blocks` are already one per logical record and ordered oldest-first by+/// `firstCapturedAt` (Decision 2) — the ordering is the repository's, because+/// only it holds the timestamps.+public struct WorkExportInput: Sendable, Equatable {+ public let titleText: String+ public let siteName: String+ /// The human-confirmed Work URL, or nil — which is what decides whether the+ /// site line is a link (Req 2.2).+ public let workURLString: String?+ public let genericNotes: String+ public let blocks: [EntryExportInput]++ public init(+ titleText: String,+ siteName: String,+ workURLString: String?,+ genericNotes: String,+ blocks: [EntryExportInput]+ ) {+ self.titleText = titleText+ self.siteName = siteName+ self.workURLString = workURLString+ self.genericNotes = genericNotes+ self.blocks = blocks+ }+}++public enum MarkdownExport {++ // MARK: - Documents++ /// One entry as a standalone document (Req 1): heading, work line where the+ /// entry has a work (1.9), date line, then the note verbatim.+ public static func renderEntry(_ input: EntryExportInput) -> String {+ document(entryParagraphs(input, includingWorkLine: true))+ }++ /// One work as a document (Req 2): H1, site line, generic notes where+ /// non-empty, then one block per entry — none of which carries a work line,+ /// because the H1 already names the work (1.9).+ public static func renderWork(_ input: WorkExportInput) -> String {+ var paragraphs: [String] = []+ let title = escape(collapsed(input.titleText))+ // A work with no title has nothing to title the document with. Both+ // write paths refuse a blank title, so this is a synced-junk case only:+ // the site line and the blocks still carry the document.+ if !title.isEmpty { paragraphs.append("# " + title) }++ let siteName = escape(collapsed(input.siteName))+ if let workURL = input.workURLString, !workURL.isEmpty {+ paragraphs.append("[\(siteName)](\(encodeLinkTarget(workURL)))")+ } else if !siteName.isEmpty {+ paragraphs.append(siteName)+ }++ let notes = input.genericNotes.trimmingCharacters(in: .whitespacesAndNewlines)+ if !notes.isEmpty { paragraphs.append(input.genericNotes) }++ for block in input.blocks {+ paragraphs.append(contentsOf: entryParagraphs(block, includingWorkLine: false))+ }+ return document(paragraphs)+ }++ // MARK: - Filenames++ /// The share sheet's filename for a standalone entry export (Req 1.5, Q11).+ public static func filename(for input: EntryExportInput) -> String {+ filename(title: input.headingText, fallback: "Entry")+ }++ /// The share sheet's filename for a per-work export (Req 2.4, Q11).+ public static func filename(for input: WorkExportInput) -> String {+ filename(title: input.titleText, fallback: "Work")+ }++ // MARK: - Blocks++ /// One entry block's paragraphs, in order.+ ///+ /// Every one of them is its own paragraph (Q18): adjacent lines merge into a+ /// single paragraph in CommonMark, and a blank line is the only separator an+ /// editor cannot trim away.+ private static func entryParagraphs(+ _ input: EntryExportInput, includingWorkLine: Bool+ ) -> [String] {+ var paragraphs: [String] = []++ let heading = escape(collapsed(input.headingText))+ // A heading with no text would leave the block's one identity link with+ // nothing to click. The URL is already in the document, so showing it is+ // no new metadata (Req 1.6).+ // Collapsed like every other title-bearing value: a raw URL carrying a+ // stray newline would otherwise split the link across two paragraphs.+ let headingText = heading.isEmpty ? escape(collapsed(input.rawURLString)) : heading+ paragraphs.append("## [\(headingText)](\(encodeLinkTarget(input.rawURLString)))")++ if includingWorkLine, let line = workLine(input) { paragraphs.append(line) }++ paragraphs.append(dateLine(input))++ let note = input.note.trimmingCharacters(in: .whitespacesAndNewlines)+ // Verbatim, deliberately: the reader's own prose is the one thing the+ // renderer never rewrites (Req 1.7).+ if !note.isEmpty { paragraphs.append(input.note) }++ return paragraphs+ }++ /// `*Title* (type)` — the type omitted where it is `.other`, the whole line+ /// omitted where the work names nothing (Decision 4).+ private static func workLine(_ input: EntryExportInput) -> String? {+ guard let title = input.workTitle else { return nil }+ let escaped = escape(collapsed(title))+ guard !escaped.isEmpty else { return nil }+ guard let label = input.workTypeLabel.map({ escape(collapsed($0)) }), !label.isEmpty else {+ return "*\(escaped)*"+ }+ return "*\(escaped)* (\(label))"+ }++ /// `▲ · date`, `▼ · date`, or the date alone (Req 1.3).+ private static func dateLine(_ input: EntryExportInput) -> String {+ switch input.rating {+ case .up: "▲ · \(input.dateText)"+ case .down: "▼ · \(input.dateText)"+ case nil: input.dateText+ }+ }++ private static func document(_ paragraphs: [String]) -> String {+ paragraphs.joined(separator: "\n\n") + "\n"+ }++ // MARK: - Text rules (1.7, 2.6)++ /// Surrounding whitespace trimmed, internal newlines collapsed to a single+ /// space. Applied to every title-bearing value before escaping.+ internal static func collapsed(_ text: String) -> String {+ text+ .split(whereSeparator: \.isNewline)+ .map { $0.trimmingCharacters(in: .whitespaces) }+ .filter { !$0.isEmpty }+ .joined(separator: " ")+ }++ /// The markdown punctuation a title must not be parsed as. Backslash goes+ /// first, or the escapes this adds are themselves escaped.+ private static let escapedCharacters: [Character] = ["\\", "[", "]", "*", "_", "`", "#", "<", ">"]++ internal static func escape(_ text: String) -> String {+ var result = ""+ result.reserveCapacity(text.count)+ for character in text {+ if escapedCharacters.contains(character) { result.append("\\") }+ result.append(character)+ }+ return result+ }++ /// Percent-encodes only what would end a markdown link target early: the+ /// parentheses and the spaces. The stored raw URL is never rewritten, and+ /// nothing else is touched — re-encoding an already-encoded URL is how a+ /// working link becomes a broken one.+ internal static func encodeLinkTarget(_ url: String) -> String {+ var encoded = ""+ encoded.reserveCapacity(url.count)+ for character in url {+ switch character {+ case " ": encoded += "%20"+ case "(": encoded += "%28"+ case ")": encoded += "%29"+ default: encoded.append(character)+ }+ }+ return encoded+ }++ // MARK: - Filename rules++ /// Characters a filename cannot carry on the platforms an exported document+ /// travels to. Stripped rather than substituted, so no separator is invented+ /// that the title did not have.+ private static let forbiddenFilenameCharacters: Set<Character> = [+ "/", ":", "\\", "?", "%", "*", "|", "\"", "<", ">",+ ]++ private static func filename(title: String, fallback: String) -> String {+ let stripped = collapsed(title)+ .filter { !forbiddenFilenameCharacters.contains($0) && !$0.isNewline }+ let name = String(stripped.prefix(80))+ .trimmingCharacters(in: .whitespaces)+ return (name.isEmpty ? fallback : name) + ".md"+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ReSharePresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/ReSharePresentation.swiftindex c24c286..638d997 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ReSharePresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ReSharePresentation.swift@@ -73,8 +73,3 @@ public enum ReShareActionLabels { } } -/// Layout constants for the re-share UI (Req 7.2).-public enum ReShareLayoutConstants {- /// Minimum hit target in points (requirement 7.2: 44×44).- public static let minimumHitTarget: CGFloat = 44-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift b/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swiftindex d22b9af..06a316d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift@@ -119,6 +119,34 @@ public struct SiteSnapshot: Equatable, Sendable { public let junkSuffixRule: JunkSuffixRule? } +#if DEBUG+extension SiteSnapshot {+ /// A fixture seam, not a production producer (Q41).+ ///+ /// The repository stays the only thing that *reads* a Site into a snapshot —+ /// this factory does not exist in a release build, so no shipped code can+ /// construct one, and its name says what it is for. It exists because the+ /// Sites view models are in the app target and their tests have to state the+ /// rows they are about.+ public static func fixture(+ hostname: String,+ displayName: String,+ mode: SiteMode,+ patternIDs: [UUID] = [],+ urlIdentityRule: URLIdentityRule? = nil,+ junkSuffixRule: JunkSuffixRule? = nil+ ) -> SiteSnapshot {+ SiteSnapshot(+ hostname: hostname,+ displayName: displayName,+ mode: mode,+ patternIDs: patternIDs,+ urlIdentityRule: urlIdentityRule,+ junkSuffixRule: junkSuffixRule)+ }+}+#endif+ public struct DatedEntryGroup: Equatable, Sendable { public let day: Date
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/AdaptiveColor.swift b/Packages/AsterismCore/Sources/ConstellationKit/AdaptiveColor.swiftnew file mode 100644index 0000000..2fdc0e5--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/AdaptiveColor.swift@@ -0,0 +1,61 @@+import SwiftUI++#if canImport(UIKit)+import UIKit+#endif++/// An sRGB component triple with an alpha channel.+///+/// The token tables in `AsterismColors` are written as pairs of these — one+/// value per appearance — and resolved into a single adaptive `Color` by+/// ``AsterismColors/adaptive(dark:light:)``.+struct ConstellationRGBA: Sendable, Equatable {+ let red: Double+ let green: Double+ let blue: Double+ let alpha: Double++ init(_ red: Double, _ green: Double, _ blue: Double, _ alpha: Double = 1) {+ self.red = red+ self.green = green+ self.blue = blue+ self.alpha = alpha+ }++ /// The same color at a different alpha. Used where the style guide names one+ /// hue at several opacities (fill, border, glow).+ func opacity(_ alpha: Double) -> ConstellationRGBA {+ ConstellationRGBA(red, green, blue, alpha)+ }+}++extension AsterismColors {+ /// Resolves an appearance pair into one adaptive `Color`.+ ///+ /// On UIKit platforms this is a `UIColor` dynamic provider, so every token+ /// switches with the system appearance without a call site ever naming an+ /// appearance (requirement 8.2). Elsewhere — notably the macOS host that+ /// runs `make test-core` — the dark value stands in; nothing there asserts+ /// colors, it only has to compile.+ static func adaptive(dark: ConstellationRGBA, light: ConstellationRGBA) -> Color {+ #if canImport(UIKit)+ return Color(uiColor: UIColor { traits in+ let components = traits.userInterfaceStyle == .light ? light : dark+ return UIColor(+ red: components.red,+ green: components.green,+ blue: components.blue,+ alpha: components.alpha+ )+ })+ #else+ return Color(+ .sRGB,+ red: dark.red,+ green: dark.green,+ blue: dark.blue,+ opacity: dark.alpha+ )+ #endif+ }+}
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/AsterismColors.swift b/Packages/AsterismCore/Sources/ConstellationKit/AsterismColors.swiftnew file mode 100644index 0000000..43f04ca--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/AsterismColors.swift@@ -0,0 +1,180 @@+import SwiftUI++/// Semantic color tokens for the Constellation design language.+///+/// Every token is a single adaptive `Color`: there are no `*Dark`/`*Light`+/// accessors, deliberately, so no call site can pin an appearance+/// (requirement 8.2). sRGB components are the oklch values of+/// `docs/asterism-style-guide.md` §2/§4/§5 converted once, here.+public enum AsterismColors {+ // MARK: - Accent: Cyan (primary/positive)++ /// `oklch(0.82 0.12 220)` dark / `oklch(0.50 0.13 220)` light.+ public static let cyan = adaptive(+ dark: ConstellationRGBA(0.3497, 0.8393, 0.9791),+ light: ConstellationRGBA(0.0, 0.4470, 0.5872)+ )++ // MARK: - Accent: Violet (secondary/negative)++ /// `oklch(0.82 0.12 305)` dark / `oklch(0.50 0.13 305)` light.+ public static let violet = adaptive(+ dark: ConstellationRGBA(0.8396, 0.6905, 1.0),+ light: ConstellationRGBA(0.4579, 0.3006, 0.6187)+ )++ // MARK: - Accent: Amber (actionable attention — teaching and duplicate review)++ /// Fill/border amber. `oklch(0.82 0.12 85)` dark / `oklch(0.55 0.12 80)` light.+ ///+ /// Per Decision 1 of `specs/polish-and-export`, amber's meaning is+ /// "needs the reader's attention" — teaching/unparsed *and*+ /// duplicate-review surfaces.+ public static let amber = adaptive(+ dark: ConstellationRGBA(0.9088, 0.7447, 0.3860),+ light: ConstellationRGBA(0.5871, 0.4073, 0.0)+ )++ /// The amber edge an actionable-attention surface wears: the attention pill,+ /// the interstitial and error panels, and the rows that carry the same mark.+ ///+ /// A named token rather than `amber.opacity(0.45)` at eight call sites —+ /// Decision 1's accent is one thing, so it has one spelling.+ public static let attentionBorder = amber.opacity(0.45)++ /// Amber for text: the lifted variant on dark, the darkened one on light+ /// (requirement 11.4 — the raw fill fails contrast as text on light).+ /// `oklch(0.85 0.11 85)` dark / `oklch(0.42 0.10 78)` light.+ public static let amberText = adaptive(+ dark: ConstellationRGBA(0.9364, 0.7858, 0.4641),+ light: ConstellationRGBA(0.4169, 0.2679, 0.0)+ )++ // MARK: - Neutrals++ public static let primaryText = adaptive(+ dark: ConstellationRGBA(0.9137, 0.9255, 0.9608),+ light: ConstellationRGBA(0.1059, 0.1176, 0.1686)+ )++ public static let secondaryText = adaptive(+ dark: ConstellationRGBA(0.5608, 0.5922, 0.6784),+ light: ConstellationRGBA(0.4000, 0.4275, 0.5098)+ )++ public static let noteText = adaptive(+ dark: ConstellationRGBA(0.7137, 0.7412, 0.8196),+ light: ConstellationRGBA(0.2392, 0.2627, 0.3373)+ )++ // MARK: - Background layer (§2 "the sky")++ public static let backgroundTop = adaptive(+ dark: ConstellationRGBA(0.0392, 0.0471, 0.0941),+ light: ConstellationRGBA(0.9647, 0.9686, 0.9843)+ )++ public static let backgroundBottom = adaptive(+ dark: ConstellationRGBA(0.0196, 0.0235, 0.0510),+ light: ConstellationRGBA(0.9255, 0.9333, 0.9608)+ )++ /// Top-right aurora ellipse.+ public static let auraViolet = adaptive(+ dark: ConstellationRGBA(0.4469, 0.3186, 0.5847, 0.36),+ light: ConstellationRGBA(0.8477, 0.7640, 0.9537, 0.65)+ )++ /// Bottom-left aurora ellipse.+ public static let auraCyan = adaptive(+ dark: ConstellationRGBA(0.0, 0.4408, 0.5575, 0.31),+ light: ConstellationRGBA(0.6033, 0.8515, 0.9318, 0.59)+ )++ /// Star dots. Dark only — the light sky has no stars (8.1), so the light+ /// value is fully transparent rather than a second appearance's worth of+ /// geometry.+ public static let star = adaptive(+ dark: ConstellationRGBA(1, 1, 1),+ light: ConstellationRGBA(1, 1, 1, 0)+ )++ // MARK: - Surfaces (§4)++ /// Card/row fill, layered over the sky.+ public static let cardFill = adaptive(+ dark: ConstellationRGBA(0.5882, 0.7059, 1.0, 0.055),+ light: ConstellationRGBA(1.0, 1.0, 1.0, 0.66)+ )++ public static let cardBorder = adaptive(+ dark: ConstellationRGBA(0.6667, 0.7843, 1.0, 0.13),+ light: ConstellationRGBA(0.1176, 0.1569, 0.3137, 0.10)+ )++ /// The specular top edge — "the single most load-bearing detail of the+ /// style" (§4).+ public static let specularEdge = adaptive(+ dark: ConstellationRGBA(1, 1, 1, 0.10),+ light: ConstellationRGBA(1, 1, 1, 0.90)+ )++ /// Flat field fill inside sheets: no border, no blur (§4, anti-rule §11).+ public static let fieldFill = adaptive(+ dark: ConstellationRGBA(0.5882, 0.7059, 1.0, 0.07),+ light: ConstellationRGBA(0.1176, 0.1569, 0.3137, 0.05)+ )++ /// Quieter-than-card fill for the provenance disclosure (§7).+ public static let quietFill = adaptive(+ dark: ConstellationRGBA(0.5882, 0.7059, 1.0, 0.035),+ light: ConstellationRGBA(0.1176, 0.1569, 0.3137, 0.035)+ )++ /// Sheet glass fill (§4). Used where the system's own sheet material is not+ /// the presenting surface — chiefly the share extension's hosted root.+ public static let sheetFill = adaptive(+ dark: ConstellationRGBA(0.0627, 0.0863, 0.1647, 0.68),+ light: ConstellationRGBA(1.0, 1.0, 1.0, 0.68)+ )++ public static let sheetBorder = adaptive(+ dark: ConstellationRGBA(0.6667, 0.7843, 1.0, 0.22),+ light: ConstellationRGBA(0.1176, 0.1569, 0.3137, 0.12)+ )++ // MARK: - Opaque fallbacks (Reduce Transparency, §10 / requirement 11.1)++ /// `#12162a` dark / `#f2f3f8` light. These *replace* the material fill when+ /// Reduce Transparency is on; they never layer under one.+ public static let opaqueCard = adaptive(+ dark: ConstellationRGBA(0.0706, 0.0863, 0.1647),+ light: ConstellationRGBA(0.9490, 0.9529, 0.9725)+ )++ /// Opaque stand-in for the flat sheet field.+ public static let opaqueField = adaptive(+ dark: ConstellationRGBA(0.1020, 0.1216, 0.2039),+ light: ConstellationRGBA(0.9098, 0.9176, 0.9451)+ )++ // MARK: - Primary button gradient (§7)++ /// `oklch(0.72 0.13 220)` dark / `oklch(0.56 0.13 220)` light.+ public static let primaryButtonStart = adaptive(+ dark: ConstellationRGBA(0.0365, 0.7159, 0.8646),+ light: ConstellationRGBA(0.0, 0.5185, 0.6611)+ )++ /// `oklch(0.68 0.14 305)` dark / `oklch(0.52 0.14 305)` light.+ public static let primaryButtonEnd = adaptive(+ dark: ConstellationRGBA(0.6765, 0.5023, 0.8668),+ light: ConstellationRGBA(0.4864, 0.3138, 0.6608)+ )++ /// `oklch(0.7 0.13 262)` at the §5 alphas — the primary button's glow.+ public static let primaryButtonGlow = adaptive(+ dark: ConstellationRGBA(0.4437, 0.6167, 0.9372, 0.50),+ light: ConstellationRGBA(0.4437, 0.6167, 0.9372, 0.40)+ )+}
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/AsterismLayout.swift b/Packages/AsterismCore/Sources/ConstellationKit/AsterismLayout.swiftnew file mode 100644index 0000000..991beb5--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/AsterismLayout.swift@@ -0,0 +1,27 @@+import CoreGraphics++/// Shape and sizing constants — the concentric radius system of+/// `docs/asterism-style-guide.md` §6, outside-in.+public enum AsterismLayout {+ public static let sheetRadius: CGFloat = 44+ public static let bannerRadius: CGFloat = 22+ public static let cardRadius: CGFloat = 20+ public static let fieldRadius: CGFloat = 20+ /// Buttons and pills are always full capsules; the value is the capsule's+ /// height-derived radius for the places a `RoundedRectangle` is clearer.+ public static let buttonRadius: CGFloat = 21+ public static let chipRadius: CGFloat = 15+ public static let urlChipRadius: CGFloat = 12+ public static let tagRadius: CGFloat = 11++ /// Minimum interactive size, kept even where the visual is smaller+ /// (requirement 11.2 — Teach pill, tags, chips).+ public static let minHitTarget: CGFloat = 44++ /// Rating toggle capsule, 48×42 (§7).+ public static let ratingToggleSize = CGSize(width: 48, height: 42)++ /// The work-detail cover: the one rounded rectangle in the glyph family (§6).+ public static let coverSize = CGSize(width: 62, height: 84)+ public static let coverRadius: CGFloat = 14+}
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/AsterismTypography.swift b/Packages/AsterismCore/Sources/ConstellationKit/AsterismTypography.swiftnew file mode 100644index 0000000..c6e0a7f--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/AsterismTypography.swift@@ -0,0 +1,61 @@+import SwiftUI++#if canImport(UIKit)+import UIKit+#endif++/// Semantic typography tokens (`docs/asterism-style-guide.md` §3).+///+/// Every token is relative to a Dynamic Type text style rather than a point+/// size (Q31, requirement 11.3): the fixed-size `serifTitle(_ size:)` the app+/// used before did not scale. The style-guide point sizes are the *default*+/// Dynamic Type rendering of the styles named here.+public enum AsterismTypography {+ /// Navigation large titles (~33 pt at the default text size).+ public static var serifLargeTitle: Font { serif(.largeTitle) }++ /// Screen and detail headings — the work title on work detail, the chapter+ /// heading on entry detail (~20 pt).+ public static var serifHeading: Font { serif(.title3) }++ /// Work and chapter names inside rows (~15 pt).+ public static var serifRowTitle: Font { serif(.subheadline) }++ /// Serif at an arbitrary text style, for the cases the three named tokens+ /// above do not cover.+ public static func serif(_ style: Font.TextStyle, weight: Font.Weight = .medium) -> Font {+ .system(style, design: .serif).weight(weight)+ }++ /// Section header face: 11 pt bold at the default text size, scaling with+ /// Dynamic Type. Uppercasing, tracking, and the ✦ prefix are the+ /// ``ConstellationSectionHeader`` view's job.+ public static let sectionHeader: Font = .system(.caption2, design: .default).weight(.bold)++ /// Provenance disclosure and URL chips: SF Mono, 11 pt, dim (§3).+ public static let mono: Font = .system(.caption2, design: .monospaced)++ #if canImport(UIKit)+ /// The serif large-title `UIFont`, scaled for the current Dynamic Type size.+ ///+ /// Needed because the only API for a serif navigation large title is the+ /// `UINavigationBar` appearance proxy, which takes a `UIFont` and does no+ /// scaling of its own (Q27/Q31). Set this as `largeTitleTextAttributes`+ /// only — `titleTextAttributes` would leak the serif face into in-process+ /// system controllers such as `UIActivityViewController`.+ public static func scaledSerifLargeTitleFont(+ compatibleWith traits: UITraitCollection? = nil+ ) -> UIFont {+ // The *unscaled* size: UIFontMetrics does the scaling below, and+ // feeding it an already-scaled size would apply Dynamic Type twice.+ let unscaled = UIFontDescriptor.preferredFontDescriptor(+ withTextStyle: .largeTitle,+ compatibleWith: UITraitCollection(preferredContentSizeCategory: .large)+ ).pointSize+ let system = UIFont.systemFont(ofSize: unscaled, weight: .medium)+ let descriptor = system.fontDescriptor.withDesign(.serif) ?? system.fontDescriptor+ let font = UIFont(descriptor: descriptor, size: unscaled)+ return UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: font, compatibleWith: traits)+ }+ #endif+}
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationBackground.swift b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationBackground.swiftnew file mode 100644index 0000000..1a0cc2c--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationBackground.swift@@ -0,0 +1,88 @@+import SwiftUI++/// The fixed background layer — "the sky" (`docs/asterism-style-guide.md` §2,+/// requirement 8.1).+///+/// One layer per screen, behind a tab stack's content, which scrolls over it+/// (`.scrollContentBackground(.hidden)` on the lists). Never on cards, and+/// never in the share extension (requirement 10.3): sheets are their own glass+/// layer, and stacking sky under sheet glass breaks the §11 two-layer rule.+public struct ConstellationBackground: View {+ @Environment(\.colorScheme) private var colorScheme++ public init() {}++ public var body: some View {+ ZStack {+ LinearGradient(+ colors: [AsterismColors.backgroundTop, AsterismColors.backgroundBottom],+ startPoint: .top,+ endPoint: .bottom+ )+ GeometryReader { proxy in+ let size = proxy.size+ ZStack {+ aura(+ color: AsterismColors.auraViolet,+ in: size,+ center: UnitPoint(x: 0.88, y: 0.06),+ radiusFraction: 0.62+ )+ aura(+ color: AsterismColors.auraCyan,+ in: size,+ center: UnitPoint(x: 0.10, y: 0.92),+ radiusFraction: 0.58+ )+ // Light has no stars (§2); the dark-only branch is a real+ // structural difference the token set cannot express.+ if colorScheme == .dark {+ stars(in: size)+ }+ }+ }+ }+ .ignoresSafeArea()+ .accessibilityHidden(true)+ }++ private func aura(+ color: Color,+ in size: CGSize,+ center: UnitPoint,+ radiusFraction: CGFloat+ ) -> some View {+ let radius = max(size.width, size.height) * radiusFraction+ return RadialGradient(+ colors: [color, color.opacity(0)],+ center: .center,+ startRadius: 0,+ endRadius: radius+ )+ .frame(width: radius * 2, height: radius * 2)+ .position(x: size.width * center.x, y: size.height * center.y)+ .blendMode(.plusLighter)+ }++ /// Eight seeded positions, static — no twinkle in v1 (§9). Fixed rather+ /// than random so the sky does not reshuffle on every redraw.+ private static let starSeeds: [(x: CGFloat, y: CGFloat, diameter: CGFloat, opacity: Double)] = [+ (0.12, 0.08, 1.5, 0.75),+ (0.31, 0.19, 1.0, 0.45),+ (0.68, 0.11, 1.2, 0.85),+ (0.86, 0.27, 1.0, 0.50),+ (0.22, 0.46, 1.2, 0.60),+ (0.55, 0.63, 1.0, 0.40),+ (0.79, 0.72, 1.5, 0.70),+ (0.40, 0.88, 1.0, 0.55),+ ]++ private func stars(in size: CGSize) -> some View {+ ForEach(Array(Self.starSeeds.enumerated()), id: \.offset) { _, seed in+ Circle()+ .fill(AsterismColors.star.opacity(seed.opacity))+ .frame(width: seed.diameter, height: seed.diameter)+ .position(x: size.width * seed.x, y: size.height * seed.y)+ }+ }+}
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationButtonStyles.swift b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationButtonStyles.swiftnew file mode 100644index 0000000..79a0173--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationButtonStyles.swift@@ -0,0 +1,177 @@+import SwiftUI++/// The gradient primary button (§7, requirement 9.1).+///+/// **Exactly one control per screen wears this.** The gradient is the screen's+/// single "this is the thing to do" signal, and the §11 anti-rule reserves+/// gradient fills for it, the site glyphs, and the sky.+public struct ConstellationPrimaryButtonStyle: ButtonStyle {+ /// A custom `ButtonStyle` gets no automatic disabled appearance, so the+ /// style has to read it: a greyed-out gradient capsule that still glows+ /// would read as the screen's live action while refusing every tap.+ @Environment(\.isEnabled) private var isEnabled++ public init() {}++ public func makeBody(configuration: Configuration) -> some View {+ configuration.label+ .font(.body.weight(.bold))+ .foregroundStyle(.white)+ .padding(.horizontal, 20)+ .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget)+ .background {+ Capsule().fill(+ LinearGradient(+ colors: [AsterismColors.primaryButtonStart, AsterismColors.primaryButtonEnd],+ startPoint: UnitPoint(x: 0, y: 0.15),+ endPoint: UnitPoint(x: 1, y: 0.85)+ )+ )+ }+ .overlay {+ Capsule()+ .strokeBorder(+ LinearGradient(+ colors: [.white.opacity(0.3), .white.opacity(0)],+ startPoint: .top,+ endPoint: .bottom+ ),+ lineWidth: 1+ )+ .allowsHitTesting(false)+ }+ .shadow(color: isEnabled ? AsterismColors.primaryButtonGlow : .clear, radius: 13, y: 2)+ .opacity(isEnabled ? (configuration.isPressed ? 0.85 : 1) : 0.4)+ .grayscale(isEnabled ? 0 : 1)+ .contentShape(Capsule())+ }+}++/// Secondary button: capsule, card fill, card border, no gradient and no glow+/// (§7).+public struct ConstellationSecondaryButtonStyle: ButtonStyle {+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency+ @Environment(\.isEnabled) private var isEnabled++ public init() {}++ public func makeBody(configuration: Configuration) -> some View {+ configuration.label+ .font(.body.weight(.medium))+ .foregroundStyle(AsterismColors.primaryText)+ .padding(.horizontal, 18)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .background {+ if reduceTransparency {+ Capsule().fill(AsterismColors.opaqueCard)+ } else {+ ZStack {+ Capsule().fill(.thinMaterial)+ Capsule().fill(AsterismColors.cardFill)+ }+ }+ }+ .overlay {+ Capsule().strokeBorder(AsterismColors.cardBorder, lineWidth: 1)+ .allowsHitTesting(false)+ }+ .opacity(isEnabled ? (configuration.isPressed ? 0.7 : 1) : 0.4)+ .contentShape(Capsule())+ }+}++/// Tertiary button: bare dim text (§7). Still takes the 44 pt hit target+/// (requirement 11.2).+public struct ConstellationTertiaryButtonStyle: ButtonStyle {+ public init() {}++ public func makeBody(configuration: Configuration) -> some View {+ configuration.label+ .font(.body)+ .foregroundStyle(AsterismColors.secondaryText)+ .padding(.horizontal, 8)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .opacity(configuration.isPressed ? 0.6 : 1)+ .contentShape(Rectangle())+ }+}++extension ButtonStyle where Self == ConstellationPrimaryButtonStyle {+ /// One per screen (requirement 9.1).+ public static var constellationPrimary: ConstellationPrimaryButtonStyle { .init() }+}++extension ButtonStyle where Self == ConstellationSecondaryButtonStyle {+ public static var constellationSecondary: ConstellationSecondaryButtonStyle { .init() }+}++extension ButtonStyle where Self == ConstellationTertiaryButtonStyle {+ public static var constellationTertiary: ConstellationTertiaryButtonStyle { .init() }+}++/// The rating toggle (§7): a 48×42 capsule. Off is the flat field fill with a+/// dim triangle; on is the accent at .15 fill, .55 border, accent triangle, and+/// the accent's glow — cyan for ▲, violet for ▼.+///+/// A `ButtonStyle` rather than a whole view so the action and the accessibility+/// identifier stay at the call site, where the existing ones already live.+public struct ConstellationRatingToggleStyle: ButtonStyle {+ public enum Direction: Sendable {+ case up+ case down++ var accent: Color {+ switch self {+ case .up: AsterismColors.cyan+ case .down: AsterismColors.violet+ }+ }+ }++ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency++ private let direction: Direction+ private let isActive: Bool++ public init(direction: Direction, isActive: Bool) {+ self.direction = direction+ self.isActive = isActive+ }++ public func makeBody(configuration: Configuration) -> some View {+ let accent = direction.accent+ return configuration.label+ .foregroundStyle(isActive ? accent : AsterismColors.secondaryText)+ .frame(+ width: AsterismLayout.ratingToggleSize.width,+ height: AsterismLayout.ratingToggleSize.height+ )+ // Requirement 11.2: the visual is 48×42, the target is 44 pt.+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .background {+ Capsule().fill(+ isActive+ ? AnyShapeStyle(accent.opacity(0.15))+ : AnyShapeStyle(reduceTransparency ? AsterismColors.opaqueField : AsterismColors.fieldFill)+ )+ }+ .overlay {+ Capsule()+ .strokeBorder(isActive ? accent.opacity(0.55) : .clear, lineWidth: 1)+ .allowsHitTesting(false)+ }+ .shadow(color: isActive ? accent.opacity(0.35) : .clear, radius: isActive ? 9 : 0)+ .opacity(configuration.isPressed ? 0.7 : 1)+ .animation(.easeOut(duration: 0.15), value: isActive)+ .contentShape(Capsule())+ }+}++extension ButtonStyle where Self == ConstellationRatingToggleStyle {+ public static func constellationRatingToggle(+ _ direction: ConstellationRatingToggleStyle.Direction,+ isActive: Bool+ ) -> ConstellationRatingToggleStyle {+ .init(direction: direction, isActive: isActive)+ }+}
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swiftnew file mode 100644index 0000000..938eb88--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift@@ -0,0 +1,163 @@+import SwiftUI++// MARK: - Banner++/// The inbox banner (§7): an amber-tinted glass capsule. Only ever shown when+/// its count is non-zero — that gate stays at the call site.+///+/// Amber here covers both teaching/unparsed and duplicate-review states: per+/// Decision 1 of `specs/polish-and-export`, amber means "needs the reader's+/// attention", not "needs teaching" specifically.+public struct ConstellationBanner: ViewModifier {+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency++ public func body(content: Content) -> some View {+ content+ .frame(minHeight: AsterismLayout.minHitTarget)+ .background {+ if reduceTransparency {+ Capsule().fill(AsterismColors.opaqueCard)+ Capsule().fill(AsterismColors.amber.opacity(0.18))+ } else {+ Capsule().fill(.thinMaterial)+ Capsule().fill(AsterismColors.amber.opacity(0.14))+ }+ }+ .overlay {+ Capsule().strokeBorder(AsterismColors.amber.opacity(0.35), lineWidth: 1)+ .allowsHitTesting(false)+ }+ // §5: a banner gets at most a whisper of glow.+ .shadow(color: AsterismColors.amber.opacity(0.08), radius: 8)+ .contentShape(Capsule())+ }+}++// MARK: - Pills, tags, chips++/// Which of the small pill treatments a label wears.+public enum ConstellationPillKind: Sendable {+ /// Teach and Resolve pills — one recipe, both amber (Decision 1,+ /// requirement 9.4).+ case attention+ /// Works-list count pill: cyan text on a cyan .12 fill (§7).+ case count+ /// Work *type* tag: violet tint and border (§7).+ case typeTag+ /// Genre tag: the neutral card recipe.+ case genreTag++ var foreground: Color {+ switch self {+ // Requirement 11.4: amber *text* takes the lifted/darkened variant;+ // the raw fill hue fails contrast as text on light.+ case .attention: AsterismColors.amberText+ case .count: AsterismColors.cyan+ case .typeTag: AsterismColors.violet+ case .genreTag: AsterismColors.secondaryText+ }+ }++ var fill: Color {+ switch self {+ case .attention: AsterismColors.amber.opacity(0.18)+ case .count: AsterismColors.cyan.opacity(0.12)+ case .typeTag: AsterismColors.violet.opacity(0.12)+ case .genreTag: AsterismColors.cardFill+ }+ }++ var border: Color {+ switch self {+ case .attention: AsterismColors.attentionBorder+ case .count: .clear+ case .typeTag: AsterismColors.violet.opacity(0.35)+ case .genreTag: AsterismColors.cardBorder+ }+ }++ var cornerRadius: CGFloat {+ switch self {+ case .attention: AsterismLayout.buttonRadius+ case .count: AsterismLayout.urlChipRadius+ case .typeTag, .genreTag: AsterismLayout.tagRadius+ }+ }+}++/// Teach chip roles (§7). Chips are the teaching sheet's vocabulary, so the+/// selected state is the one that glows.+public enum ConstellationChipRole: Sendable {+ case chapter+ case work+ case ignored++ var accent: Color {+ switch self {+ case .chapter: AsterismColors.amberText+ case .work: AsterismColors.cyan+ case .ignored: AsterismColors.secondaryText+ }+ }++ var fill: Color {+ switch self {+ case .chapter: AsterismColors.amber.opacity(0.13)+ case .work: AsterismColors.cyan.opacity(0.13)+ case .ignored: AsterismColors.cardFill+ }+ }++ var border: Color {+ switch self {+ case .chapter: AsterismColors.amber.opacity(0.8)+ case .work: AsterismColors.cyan.opacity(0.8)+ case .ignored: AsterismColors.cardBorder+ }+ }+}++extension View {+ /// The inbox / duplicate-review banner capsule.+ public func constellationBanner() -> some View {+ modifier(ConstellationBanner())+ }++ /// A small pill or tag. Visuals are smaller than 44 pt by design; the+ /// minimum hit target is applied anyway (requirement 11.2).+ public func constellationPill(_ kind: ConstellationPillKind) -> some View {+ let shape = RoundedRectangle(cornerRadius: kind.cornerRadius, style: .continuous)+ return self+ .font(.caption.weight(.semibold))+ .foregroundStyle(kind.foreground)+ .padding(.horizontal, 10)+ .padding(.vertical, 4)+ .background { shape.fill(kind.fill) }+ .overlay { shape.strokeBorder(kind.border, lineWidth: 1).allowsHitTesting(false) }+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .contentShape(shape)+ }++ /// A teach chip. `isSelected` adds the §5 glow — the only glow chips get.+ public func constellationChip(+ role: ConstellationChipRole,+ isSelected: Bool = false,+ isMonospaced: Bool = false+ ) -> some View {+ let radius = isMonospaced ? AsterismLayout.urlChipRadius : AsterismLayout.chipRadius+ let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)+ return self+ .font(isMonospaced ? AsterismTypography.mono : .footnote.weight(.medium))+ .foregroundStyle(role.accent)+ .padding(.horizontal, 10)+ .padding(.vertical, 5)+ .background { shape.fill(role.fill) }+ .overlay { shape.strokeBorder(role.border, lineWidth: 1.5).allowsHitTesting(false) }+ .shadow(color: isSelected ? role.accent.opacity(0.2) : .clear, radius: isSelected ? 9 : 0)+ .opacity(role == .ignored ? 0.5 : 1)+ // Requirement 11.2, as `constellationPill` does: a one- or+ // two-character chip is narrower than 44 pt on its own.+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .contentShape(shape)+ }+}
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationSectionHeader.swift b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationSectionHeader.swiftnew file mode 100644index 0000000..ddf076b--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationSectionHeader.swift@@ -0,0 +1,46 @@+import SwiftUI++/// Section header treatment (requirement 8.4, §3): 11 pt bold uppercase,+/// letter-spaced, dim, with a small ✦ prefix — cyan on the first section of a+/// screen, violet on the ones after it.+public struct ConstellationSectionHeader: View {+ public enum Accent: Sendable {+ case cyan+ case violet++ var color: Color {+ switch self {+ case .cyan: AsterismColors.cyan+ case .violet: AsterismColors.violet+ }+ }+ }++ private let text: String+ private let accent: Accent++ public init(_ text: String, accent: Accent = .cyan) {+ self.text = text+ self.accent = accent+ }++ /// One concatenated `Text` rather than an `HStack` of an `Image` and a+ /// `Text`: a header carrying an accessibility identifier has to stay a+ /// static-text element for the UI suites that query it that way+ /// (`unattached-section-header`), and a container would report as an+ /// "other" element instead (Q42).+ public var body: some View {+ Text("\(sparkle) \(label)")+ .font(AsterismTypography.sectionHeader)+ }++ private var sparkle: Text {+ Text(Image(systemName: "sparkle")).foregroundStyle(accent.color)+ }++ private var label: Text {+ Text(text.uppercased())+ .tracking(1.1)+ .foregroundStyle(AsterismColors.secondaryText)+ }+}
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationSurfaces.swift b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationSurfaces.swiftnew file mode 100644index 0000000..da51d18--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationSurfaces.swift@@ -0,0 +1,222 @@+import SwiftUI++// MARK: - Specular top edge++/// The 1 pt inner highlight along a surface's top edge — "the single most+/// load-bearing detail of the style" (§4).+public struct SpecularTopEdge: ViewModifier {+ let cornerRadius: CGFloat++ public func body(content: Content) -> some View {+ content.overlay {+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)+ .strokeBorder(+ LinearGradient(+ colors: [AsterismColors.specularEdge, AsterismColors.specularEdge.opacity(0)],+ startPoint: .top,+ endPoint: .bottom+ ),+ lineWidth: 1+ )+ .allowsHitTesting(false)+ }+ }+}++extension View {+ /// Adds the specular top edge at the given radius.+ public func specularTopEdge(cornerRadius: CGFloat = AsterismLayout.cardRadius) -> some View {+ modifier(SpecularTopEdge(cornerRadius: cornerRadius))+ }+}++// MARK: - Cards and rows++/// Card/row surface: material fill, card border, specular top edge (§4, Q26).+///+/// Liquid Glass proper stays in the control and navigation layer — per-row+/// `glassEffect` shapes cannot share a `GlassEffectContainer` across list row+/// hosts, so content rows use a material instead.+///+/// Under Reduce Transparency the opaque fill **replaces** the material rather+/// than layering under it (requirement 11.1): layering would leave the §10+/// named fills invisible behind a translucency the setting was meant to remove.+public struct ConstellationCard: ViewModifier {+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency++ let cornerRadius: CGFloat+ let borderColor: Color?++ public func body(content: Content) -> some View {+ let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)+ return content+ .background {+ if reduceTransparency {+ shape.fill(AsterismColors.opaqueCard)+ } else {+ ZStack {+ shape.fill(.thinMaterial)+ shape.fill(AsterismColors.cardFill)+ }+ }+ }+ .overlay {+ shape.strokeBorder(borderColor ?? AsterismColors.cardBorder, lineWidth: 1)+ .allowsHitTesting(false)+ }+ .specularTopEdge(cornerRadius: cornerRadius)+ .contentShape(shape)+ }+}++/// The flat field treatment for content inside sheets: no border, no blur,+/// no shadow (§4, anti-rule §11).+public struct ConstellationField: ViewModifier {+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency++ let cornerRadius: CGFloat++ public func body(content: Content) -> some View {+ let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)+ return content.background {+ shape.fill(reduceTransparency ? AsterismColors.opaqueField : AsterismColors.fieldFill)+ }+ }+}++extension View {+ /// Card/row surface treatment. Apply to a row whose `listRowBackground` has+ /// been cleared, or to a free-standing card.+ public func constellationCard(+ cornerRadius: CGFloat = AsterismLayout.cardRadius,+ borderColor: Color? = nil+ ) -> some View {+ modifier(ConstellationCard(cornerRadius: cornerRadius, borderColor: borderColor))+ }++ /// Clears a `List` row so the content's own card surface is what shows:+ /// system row background and separator go, and the insets leave the cards+ /// floating over the sky (requirement 8.1).+ ///+ /// Pair with ``constellationCard(cornerRadius:borderColor:)`` on the row's+ /// content — this modifier only removes the system chrome.+ public func constellationListRow(+ insets: EdgeInsets = EdgeInsets(top: 5, leading: 16, bottom: 5, trailing: 16)+ ) -> some View {+ listRowBackground(Color.clear)+ .listRowSeparator(.hidden)+ .listRowInsets(insets)+ }++ /// Flat field fill for content inside a sheet.+ public func constellationField(+ cornerRadius: CGFloat = AsterismLayout.fieldRadius+ ) -> some View {+ modifier(ConstellationField(cornerRadius: cornerRadius))+ }++ /// The dashed, lower-fill group used for unattached notes (§7).+ public func constellationDashedGroup(+ cornerRadius: CGFloat = AsterismLayout.cardRadius+ ) -> some View {+ let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)+ return self+ .background { shape.fill(AsterismColors.quietFill) }+ .overlay {+ shape+ .strokeBorder(+ AsterismColors.cardBorder,+ style: StrokeStyle(lineWidth: 1, dash: [5, 4])+ )+ .allowsHitTesting(false)+ }+ }++ /// Provenance disclosure surface: quieter than a card, no border (§7).+ /// Pair with ``AsterismTypography/mono``.+ public func constellationProvenanceSurface(+ cornerRadius: CGFloat = AsterismLayout.urlChipRadius+ ) -> some View {+ background {+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)+ .fill(AsterismColors.quietFill)+ }+ }++ /// The amber-washed, amber-edged panel for actionable attention.+ public func constellationAttentionSurface(+ cornerRadius: CGFloat = AsterismLayout.cardRadius+ ) -> some View {+ modifier(ConstellationAttentionSurface(cornerRadius: cornerRadius))+ }++ /// Sheet glass, for a sheet root the system is not already rendering as one+ /// — chiefly the share extension's hosted SwiftUI root (requirement 10.1).+ public func constellationSheetSurface(+ cornerRadius: CGFloat = AsterismLayout.sheetRadius+ ) -> some View {+ modifier(ConstellationSheetSurface(cornerRadius: cornerRadius))+ }+}++/// Actionable-attention surface (Decision 1 of `specs/polish-and-export`): an+/// amber wash and the amber edge, for a panel the reader has to read and clear+/// — the share extension's error banners and the teaching interstitial.+///+/// The border is ``AsterismColors/attentionBorder``, the same value+/// ``ConstellationPillKind/attention`` wears, so the panel and the pill that+/// sends the reader to it are marked identically.+///+/// Under Reduce Transparency the opaque card fill goes *under* the wash, as+/// ``ConstellationBanner`` does: an amber tint alone is a wash over whatever is+/// behind it, and what is behind it is exactly what the setting exists to+/// remove (requirement 11.1).+public struct ConstellationAttentionSurface: ViewModifier {+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency++ let cornerRadius: CGFloat++ public func body(content: Content) -> some View {+ let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)+ return content+ .background {+ if reduceTransparency {+ shape.fill(AsterismColors.opaqueCard)+ shape.fill(AsterismColors.amber.opacity(0.18))+ } else {+ shape.fill(AsterismColors.amber.opacity(0.12))+ }+ }+ .overlay {+ shape.strokeBorder(AsterismColors.attentionBorder, lineWidth: 1)+ .allowsHitTesting(false)+ }+ }+}++/// Sheet glass (§4): fill, border, specular edge, faint ambient glow.+public struct ConstellationSheetSurface: ViewModifier {+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency++ let cornerRadius: CGFloat++ public func body(content: Content) -> some View {+ let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)+ return content+ .background {+ if reduceTransparency {+ shape.fill(AsterismColors.opaqueCard)+ } else {+ ZStack {+ shape.fill(.regularMaterial)+ shape.fill(AsterismColors.sheetFill)+ }+ }+ }+ .overlay {+ shape.strokeBorder(AsterismColors.sheetBorder, lineWidth: 1)+ .allowsHitTesting(false)+ }+ .specularTopEdge(cornerRadius: cornerRadius)+ }+}
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/SiteGlyph.swift b/Packages/AsterismCore/Sources/ConstellationKit/SiteGlyph.swiftnew file mode 100644index 0000000..bcfdbdd--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/SiteGlyph.swift@@ -0,0 +1,142 @@+import Foundation+import SwiftUI++/// What a site glyph is standing for.+public enum SiteGlyphKind: Sendable, Hashable {+ /// A known site: a colored circle with the radial star gradient.+ case site+ /// An unrecognised host. Flat, dim, unglowed (§6).+ case unknown+ /// An articles-mode site. Flat, dim, unglowed.+ case articles+}++/// Derives a site glyph's colors from its hostname.+///+/// `Site` carries no color field in any schema version, and M5 bumps no schema+/// (Q9), so the color has to be a pure function of the hostname. It also has to+/// be stable across launches and releases — a glyph whose hue moves is not an+/// identity — which is why ``hue(forHostname:)`` is pinned by golden tests.+public enum SiteGlyphPalette {+ /// Lightness and chroma are locked (§2: "vary only hue"); these are the two+ /// stops of the style guide's own Royal Road example expressed in oklch.+ private static let lightStop = (lightness: 0.628, chroma: 0.132)+ private static let darkStop = (lightness: 0.425, chroma: 0.119)++ /// The hostname's hue in degrees, `0..<360`.+ ///+ /// FNV-1a (32-bit) over the normalised hostname's UTF-8 bytes, folded into+ /// degrees. FNV-1a because it is short, has no platform-dependent seed+ /// (unlike `Hasher`, which is randomised per process), and spreads short+ /// ASCII strings well.+ public static func hue(forHostname hostname: String) -> Double {+ let normalised = hostname.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()+ var hash: UInt32 = 2_166_136_261+ for byte in normalised.utf8 {+ hash ^= UInt32(byte)+ hash = hash &* 16_777_619+ }+ return Double(hash % 360)+ }++ /// The glyph's two-stop radial gradient, lit from the top-left (§6).+ public static func gradient(forHostname hostname: String) -> Gradient {+ let hue = hue(forHostname: hostname)+ return Gradient(colors: [+ oklch(lightStop.lightness, lightStop.chroma, hue),+ oklch(darkStop.lightness, darkStop.chroma, hue),+ ])+ }++ /// The glyph's representative color — the light stop, used for the glow and+ /// for text that has to match a glyph.+ public static func accent(forHostname hostname: String) -> Color {+ oklch(lightStop.lightness, lightStop.chroma, hue(forHostname: hostname))+ }++ /// oklch → sRGB. Kept here rather than in the token table because this is+ /// the one place a hue is not known at authoring time.+ static func oklch(_ lightness: Double, _ chroma: Double, _ hueDegrees: Double) -> Color {+ let hue = hueDegrees * .pi / 180+ let a = chroma * cos(hue)+ let b = chroma * sin(hue)++ let lRoot = lightness + 0.3963377774 * a + 0.2158037573 * b+ let mRoot = lightness - 0.1055613458 * a - 0.0638541728 * b+ let sRoot = lightness - 0.0894841775 * a - 1.2914855480 * b+ let l = lRoot * lRoot * lRoot+ let m = mRoot * mRoot * mRoot+ let s = sRoot * sRoot * sRoot++ let red = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s+ let green = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s+ let blue = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s++ return Color(+ .sRGB,+ red: encodeGamma(red),+ green: encodeGamma(green),+ blue: encodeGamma(blue)+ )+ }++ private static func encodeGamma(_ value: Double) -> Double {+ let clamped = min(max(value, 0), 1)+ return clamped > 0.0031308+ ? 1.055 * pow(clamped, 1 / 2.4) - 0.055+ : 12.92 * clamped+ }+}++/// A site's identity mark: a circle with a radial star gradient, colored from+/// the hostname (requirement 9.2). Replaces plain-text hostnames in rows.+///+/// The `?` and ✎ variants are deliberately flat, dim, and unglowed — they mark+/// the *absence* of a taught identity, so they must not read as one.+public struct SiteGlyph: View {+ private let hostname: String+ private let kind: SiteGlyphKind+ private let size: CGFloat++ public init(hostname: String, kind: SiteGlyphKind = .site, size: CGFloat = 26) {+ self.hostname = hostname+ self.kind = kind+ self.size = size+ }++ public var body: some View {+ Group {+ switch kind {+ case .site: coloredGlyph+ case .unknown: dimGlyph(symbol: "questionmark")+ case .articles: dimGlyph(symbol: "pencil")+ }+ }+ .frame(width: size, height: size)+ .accessibilityHidden(true)+ }++ private var coloredGlyph: some View {+ Circle()+ .fill(+ RadialGradient(+ gradient: SiteGlyphPalette.gradient(forHostname: hostname),+ center: UnitPoint(x: 0.35, y: 0.30),+ startRadius: 0,+ endRadius: size * 0.85+ )+ )+ .shadow(color: SiteGlyphPalette.accent(forHostname: hostname).opacity(0.45), radius: 10.5)+ }++ private func dimGlyph(symbol: String) -> some View {+ Circle()+ .fill(AsterismColors.cardFill)+ .overlay(Circle().strokeBorder(AsterismColors.cardBorder, lineWidth: 1))+ .overlay(+ Image(systemName: symbol)+ .font(.system(size: size * 0.45, weight: .semibold))+ .foregroundStyle(AsterismColors.secondaryText)+ )+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesReteachFlagTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesReteachFlagTests.swiftnew file mode 100644index 0000000..b3773fd--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesReteachFlagTests.swift@@ -0,0 +1,132 @@+import Foundation+import Testing++@testable import AsterismCore++// The one way out of articles mode (Req 6.3, Decision 3): a re-teach launched+// from the Sites screen, and only from there. The gate that keeps inbox-driven+// teaching from converting an articles site by accident stays in place+// everywhere else, so both states are tested.++@Suite("Articles re-teach flag", .serialized)+struct ArticlesReteachFlagTests {+ private let host = "articles.test"++ private func titleRule() throws -> PatternDefinition {+ .segment(work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: [])+ }++ /// An articles site reached the way the app reaches it: taught into articles+ /// mode, which is what marks its entries intentionally unattached.+ private func articlesFixture() async throws -> (fixture: M5Fixture, entryID: UUID) {+ let fixture = try await M5Fixture()+ let entryID = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: host, displayName: "Articles Site")],+ entries: [+ M5SeedEntry(+ id: entryID, captureTitle: "Chapter 7 - A Serial", hostname: host, path: "7",+ workAssignmentProvenance: .none)+ ])+ let rule = try ArticleTitleCleaner.deriveRule(+ from: "Chapter 7 - A Serial", suffixSegmentCount: 1)+ let contract = try await fixture.repository.projectArticles(+ hostname: host, junkSuffixRule: rule)+ let outcome = try await fixture.repository.commitArticles(contract)+ #expect(outcome == .committed)+ #expect(try await fixture.repository.entry(id: entryID).intentionallyUnattached)+ return (fixture, entryID)+ }++ @Test("By default, composed teaching still refuses an articles site (the accident gate)")+ func defaultFlagRefusesProjection() async throws {+ let (fixture, _) = try await articlesFixture()+ await #expect(throws: LibraryRepositoryError.self) {+ _ = try await fixture.repository.projectComposedTeaching(+ hostname: self.host,+ request: ComposedTeachingRequest(titleDefinition: try self.titleRule()))+ }+ // The other teaching entry point is untouched by this milestone.+ await #expect(throws: LibraryRepositoryError.self) {+ _ = try await fixture.repository.projectInitialTeaching(+ hostname: self.host, patternDefinition: try self.titleRule())+ }+ }++ @Test("By default, the commit refuses too — the flag is not a projection-only lift")+ func defaultFlagRefusesCommit() async throws {+ let (fixture, _) = try await articlesFixture()+ let permitted = try await fixture.repository.projectComposedTeaching(+ hostname: host,+ request: ComposedTeachingRequest(+ titleDefinition: try titleRule(), acknowledgeUnsettled: true,+ permitsArticlesConversion: true))++ // The same basis and outcome the flagged projection produced, carried by+ // a request that does not permit the conversion: a caller cannot commit+ // its way past the gate by re-labelling an approved contract.+ let unpermitted = ComposedTeachingContract(+ basis: permitted.basis,+ request: ComposedTeachingRequest(+ titleDefinition: try titleRule(), acknowledgeUnsettled: true),+ outcome: permitted.outcome)+ let outcome = try await fixture.repository.commitComposedTeaching(unpermitted)+ guard case .invalidated = outcome else {+ Issue.record("expected the commit to refuse, got \(outcome)")+ return+ }+ let site = try #require(try await fixture.repository.sites().first)+ #expect(site.mode == .articles)+ }++ @Test("With the flag, the re-teach converts the site back to taught (6.3, Decision 3)")+ func flaggedReteachConverts() async throws {+ let (fixture, _) = try await articlesFixture()+ let contract = try await fixture.repository.projectComposedTeaching(+ hostname: host,+ request: ComposedTeachingRequest(+ titleDefinition: try titleRule(), acknowledgeUnsettled: true,+ permitsArticlesConversion: true))+ let outcome = try await fixture.repository.commitComposedTeaching(contract)+ guard case .committed = outcome else {+ Issue.record("expected the re-teach to commit, got \(outcome)")+ return+ }++ let site = try #require(try await fixture.repository.sites().first)+ #expect(site.mode == .taught)+ #expect(site.patternIDs.count == 1)+ // The junk rule belongs to articles mode alone, and the closed tuple+ // table refuses it on a taught Site — a library that cannot be opened is+ // not an acceptable price for keeping an inert value.+ #expect(site.junkSuffixRule == nil)++ // The conversion leaves a library the app can open.+ await fixture.repository.shutdown()+ _ = try await LibraryRepository.open(+ LibraryConfiguration(rootDirectory: fixture.directory))+ }++ @Test("Intentionally-unattached entries stay unattached through the conversion (§2.6)")+ func unattachedEntriesSurviveTheConversion() async throws {+ let (fixture, entryID) = try await articlesFixture()+ let contract = try await fixture.repository.projectComposedTeaching(+ hostname: host,+ request: ComposedTeachingRequest(+ titleDefinition: try titleRule(), acknowledgeUnsettled: true,+ permitsArticlesConversion: true))+ _ = try await fixture.repository.commitComposedTeaching(contract)++ let entry = try await fixture.repository.entry(id: entryID)+ #expect(entry.intentionallyUnattached)+ #expect(entry.workID == nil)+ // Articles mode's `.none` provenance is legal only in articles mode, so+ // the conversion promotes it to the same shape Move to…'s "Leave+ // unattached" branch writes — the mark survives, the tuple stays legal.+ #expect(+ try await fixture.repository.m5UnattachmentFields(of: entryID)+ == M5UnattachmentFields(+ hasWork: false, intentionallyUnattached: true, assignmentProvenance: .manual,+ hasWorkPatternID: false, hasWorkPatternVersion: false))+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swiftindex ba0b287..b205cb2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift@@ -288,7 +288,10 @@ struct ComposedTeachingRepositoryTests { // MARK: - Fixture -private struct ComposedRepoFixture {+/// Internal rather than file-private: `WorkDeletionTests` needs a repository+/// whose entries were assigned by a real URL rule, and this is the fixture that+/// produces one through the ordinary commit path.+struct ComposedRepoFixture { let directory: URL let configuration: LibraryConfiguration let container: ModelContainer
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ExportInputReadTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ExportInputReadTests.swiftnew file mode 100644index 0000000..67c1b13--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ExportInputReadTests.swift@@ -0,0 +1,293 @@+import Foundation+import Testing++@testable import AsterismCore++// The repository half of markdown export (Reqs 1.2, 1.8, 2.1, 2.3, 2.5): identity+// groups collapsed to one block, cleaned display titles, the locale seam, and the+// work line's fields — all resolved inside one shared lock so the renderer stays+// a pure function of what these builders hand it.++@Suite("Export input reads", .serialized)+struct ExportInputReadTests {+ private let auLocale = Locale(identifier: "en_AU")++ @Test("A torn identity group exports one block of the content the app presents (1.8, 2.5)")+ func splitGroupExportsOnce() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let entryID = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com", displayName: "Example Site")],+ works: [+ M5SeedWork(+ id: workID, displayTitle: "The Perfect Run", hostname: "example.com",+ type: .novel)+ ],+ entries: [+ M5SeedEntry(+ id: entryID, captureTitle: "Chapter 14", hostname: "example.com", path: "14",+ note: "What this device wrote", workID: workID),+ M5SeedEntry(+ id: entryID, captureTitle: "Chapter 14", hostname: "example.com", path: "14",+ note: "What the other one wrote", workID: workID),+ ])++ // Export never refuses over duplicate or torn state; it renders the same+ // deterministic carrier content every other surface shows.+ let presented = try await fixture.repository.entry(id: entryID)+ let entryInput = try await fixture.repository.entryExportInput(+ entryID: entryID, locale: auLocale)+ #expect(entryInput.note == presented.note)+ #expect(entryInput.headingText == "Chapter 14")+ #expect(entryInput.workTitle == "The Perfect Run")+ #expect(entryInput.workTypeLabel == "novel")++ let workInput = try await fixture.repository.workExportInput(+ workID: workID, locale: auLocale)+ #expect(workInput.blocks.count == 1)+ #expect(workInput.blocks[0].note == presented.note)+ // A block inside a per-work document carries no work line (1.9).+ #expect(workInput.blocks[0].workTitle == nil)+ #expect(workInput.blocks[0].workTypeLabel == nil)+ }++ @Test("Members of an unresolved duplicate set export as separate blocks (2.5)")+ func duplicateSetMembersExportSeparately() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let first = UUID()+ let second = UUID()+ let day1 = try m5LocalDate(year: 2026, month: 5, day: 12)+ let day2 = try m5LocalDate(year: 2026, month: 5, day: 13)+ // Two application UUIDs on one raw URL: the Entry duplicate relation+ // (Q8), with disagreeing notes so the set is the reader's to resolve+ // rather than one the app collapses on its own.+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+ entries: [+ M5SeedEntry(+ id: first, captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ note: "What this device wrote", firstCapturedAt: day1, lastSharedAt: day1,+ workID: workID),+ M5SeedEntry(+ id: second, captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ note: "What the other one wrote", firstCapturedAt: day2, lastSharedAt: day2,+ workID: workID),+ ])++ // The set is genuinely unresolved: it is on the reader's workload.+ let workload = try await fixture.repository+ .recentPresentation(calendar: .current).duplicateWorkload+ let item = try #require(workload.item(for: first, type: .entry))+ #expect(Set(item.memberIDs) == [first, second])++ // A split group collapses to one block; two members of a set are two+ // records, and a document that showed one would drop authored content+ // the reader has not decided about.+ let workInput = try await fixture.repository.workExportInput(+ workID: workID, locale: auLocale)+ #expect(workInput.blocks.count == 2)+ #expect(+ workInput.blocks.map(\.note)+ == ["What this device wrote", "What the other one wrote"])+ #expect(workInput.blocks.map(\.dateText) == ["12 May 2026", "13 May 2026"])+ }++ @Test("Blocks order oldest-first by firstCapturedAt, so a re-share cannot move one (2.3)")+ func firstCapturedOrdering() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let older = UUID()+ let newer = UUID()+ let day1 = try m5LocalDate(year: 2026, month: 5, day: 12)+ let day2 = try m5LocalDate(year: 2026, month: 5, day: 13)+ let day3 = try m5LocalDate(year: 2026, month: 5, day: 14)+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+ entries: [+ M5SeedEntry(+ id: older, captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ firstCapturedAt: day1, lastSharedAt: day1, workID: workID),+ // Captured later, re-shared later still: the feed floats it, the+ // document must not.+ M5SeedEntry(+ id: newer, captureTitle: "Chapter 2", hostname: "example.com", path: "2",+ firstCapturedAt: day2, lastSharedAt: day3, workID: workID),+ ])++ let activityOrder = try await fixture.repository.work(id: workID).entries.map(\.id)+ #expect(activityOrder == [newer, older])++ let workInput = try await fixture.repository.workExportInput(+ workID: workID, locale: auLocale)+ #expect(workInput.blocks.map(\.headingText) == ["Chapter 1", "Chapter 2"])+ #expect(workInput.blocks.map(\.dateText) == ["12 May 2026", "13 May 2026"])+ }++ @Test("Headings use the cleaned display title: articles junk-strip and whole-title trim (1.2)")+ func cleanedHeadings() async throws {+ let fixture = try await M5Fixture()+ let articleEntry = UUID()+ let trimmedEntry = UUID()+ let chapteredEntry = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [+ M5SeedSite(+ hostname: "articles.test", mode: .articles,+ junkExample: "Story — Example Site"),+ M5SeedSite(+ hostname: "taught.test", mode: .taught, wholeTitleRule: true,+ trimPrefix: "Read: "),+ ],+ entries: [+ M5SeedEntry(+ id: articleEntry, captureTitle: "Chapter One — Example Site",+ hostname: "articles.test", path: "one"),+ M5SeedEntry(+ id: trimmedEntry, captureTitle: "Read: The Serial", hostname: "taught.test",+ path: "serial"),+ // A chapter title outranks the cleaned capture title (1.2).+ M5SeedEntry(+ id: chapteredEntry, captureTitle: "Read: The Serial", hostname: "taught.test",+ path: "serial-2", chapterTitle: "The Long Way"),+ ])++ #expect(+ try await fixture.repository.entryExportInput(+ entryID: articleEntry, locale: auLocale).headingText == "Chapter One")+ #expect(+ try await fixture.repository.entryExportInput(+ entryID: trimmedEntry, locale: auLocale).headingText == "The Serial")+ #expect(+ try await fixture.repository.entryExportInput(+ entryID: chapteredEntry, locale: auLocale).headingText == "The Long Way")+ }++ @Test("Work-line fields come from the work group's carrier, and .other carries no label (1.9)")+ func workLineFieldsFromCarrier() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let untypedWorkID = UUID()+ let entryID = UUID()+ let untypedEntryID = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [+ // The bare row represents the group; the authored row carries it.+ M5SeedWork(+ id: workID, displayTitle: "Parsed Name", hostname: "example.com",+ titleProvenance: .parsed, lastParsedTitle: "Parsed Name"),+ M5SeedWork(+ id: workID, displayTitle: "Reader Title", hostname: "example.com",+ type: .novel, titleProvenance: .manual, lastParsedTitle: "Parsed Name"),+ M5SeedWork(+ id: untypedWorkID, displayTitle: "Untyped Work", hostname: "example.com"),+ ],+ entries: [+ M5SeedEntry(+ id: entryID, captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ workID: workID, workRowIndex: 0),+ M5SeedEntry(+ id: untypedEntryID, captureTitle: "Chapter 1", hostname: "example.com",+ path: "u1", workID: untypedWorkID),+ ])++ let carried = try await fixture.repository.entryExportInput(+ entryID: entryID, locale: auLocale)+ #expect(carried.workTitle == "Reader Title")+ #expect(carried.workTypeLabel == "novel")++ let untyped = try await fixture.repository.entryExportInput(+ entryID: untypedEntryID, locale: auLocale)+ #expect(untyped.workTitle == "Untyped Work")+ #expect(untyped.workTypeLabel == nil)+ }++ @Test("An unattached entry carries no work fields")+ func unattachedEntry() async throws {+ let fixture = try await M5Fixture()+ let entryID = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ entries: [+ M5SeedEntry(+ id: entryID, captureTitle: "Loose Note", hostname: "example.com", path: "loose")+ ])+ let input = try await fixture.repository.entryExportInput(+ entryID: entryID, locale: auLocale)+ #expect(input.workTitle == nil)+ #expect(input.workTypeLabel == nil)+ }++ @Test("The site line uses the Site's display name, falling back to the hostname")+ func siteDisplayNameFallback() async throws {+ let fixture = try await M5Fixture()+ let named = UUID()+ let unknown = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com", displayName: "Example Site")],+ works: [+ M5SeedWork(id: named, displayTitle: "Named", hostname: "example.com"),+ // No Site row for this hostname at all — a tolerated state the+ // export must not refuse over.+ M5SeedWork(id: unknown, displayTitle: "Orphaned", hostname: "orphan.test"),+ ])+ #expect(+ try await fixture.repository.workExportInput(workID: named, locale: auLocale).siteName+ == "Example Site")+ #expect(+ try await fixture.repository.workExportInput(workID: unknown, locale: auLocale).siteName+ == "orphan.test")+ }++ @Test("The locale parameter reaches the date text (1.3, Q8)")+ func localeReachesDateText() async throws {+ let fixture = try await M5Fixture()+ let entryID = UUID()+ let day = try m5LocalDate(year: 2026, month: 5, day: 12)+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ entries: [+ M5SeedEntry(+ id: entryID, captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ firstCapturedAt: day, lastSharedAt: day)+ ])+ #expect(+ try await fixture.repository.entryExportInput(entryID: entryID, locale: auLocale)+ .dateText == "12 May 2026")+ #expect(+ try await fixture.repository.entryExportInput(+ entryID: entryID, locale: Locale(identifier: "en_US")).dateText == "May 12, 2026")+ }++ @Test("A work with no entries exports its title, site, URL and notes only (2.1)")+ func emptyWorkInput() async throws {+ let fixture = try await M5Fixture()+ let linked = UUID()+ let unlinked = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com", displayName: "Example Site")],+ works: [+ M5SeedWork(+ id: linked, displayTitle: "A Serial", hostname: "example.com",+ genericNotes: "Re-reading.",+ workURLString: "https://example.com/serial"),+ M5SeedWork(id: unlinked, displayTitle: "Another", hostname: "example.com"),+ ])++ let input = try await fixture.repository.workExportInput(workID: linked, locale: auLocale)+ #expect(input.titleText == "A Serial")+ #expect(input.siteName == "Example Site")+ #expect(input.workURLString == "https://example.com/serial")+ #expect(input.genericNotes == "Re-reading.")+ #expect(input.blocks.isEmpty)++ let bare = try await fixture.repository.workExportInput(workID: unlinked, locale: auLocale)+ #expect(bare.workURLString == nil)+ #expect(bare.genericNotes.isEmpty)+ #expect(bare.blocks.isEmpty)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swiftnew file mode 100644index 0000000..5b75446--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift@@ -0,0 +1,303 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// Row-level seeding for the M5 repository suites (export inputs, work detail,+// work deletion).+//+// The shapes these suites are about — a split identity group, a torn one, an+// Entry group whose rows span two members of a Work duplicate set — cannot be+// produced through the write paths, which is the point of them. They are written+// straight into a locked context, exactly as `ToleratedStateFixture` does, and+// the seeders live in an extension on the actor so no closure crosses an+// isolation boundary.++/// One Site row.+struct M5SeedSite: Sendable {+ var hostname: String+ var displayName: String?+ var mode: SiteMode = .untaught+ /// A junk-suffix rule derived from this example, for an articles Site.+ var junkExample: String?+ var junkSuffixSegments: Int = 1+ /// A whole-title (Work-only) active pattern with these trims, for a taught Site.+ var wholeTitleRule: Bool = false+ var trimPrefix: String?+ var trimSuffix: String?++ init(+ hostname: String, displayName: String? = nil, mode: SiteMode = .untaught,+ junkExample: String? = nil, junkSuffixSegments: Int = 1, wholeTitleRule: Bool = false,+ trimPrefix: String? = nil, trimSuffix: String? = nil+ ) {+ self.hostname = hostname+ self.displayName = displayName+ self.mode = mode+ self.junkExample = junkExample+ self.junkSuffixSegments = junkSuffixSegments+ self.wholeTitleRule = wholeTitleRule+ self.trimPrefix = trimPrefix+ self.trimSuffix = trimSuffix+ }+}++/// One Work **row**. Two rows sharing `id` are one split (or torn) group.+struct M5SeedWork: Sendable {+ var id: UUID+ var displayTitle: String+ var hostname: String+ var type: WorkType = .other+ var titleProvenance: TitleProvenance = .manual+ var lastParsedTitle: String?+ var genericNotes: String = ""+ var workURLString: String?+ var urlIdentity: String?+ var createdAt: Date = M5Fixture.epoch++ init(+ id: UUID, displayTitle: String, hostname: String, type: WorkType = .other,+ titleProvenance: TitleProvenance = .manual, lastParsedTitle: String? = nil,+ genericNotes: String = "", workURLString: String? = nil, urlIdentity: String? = nil,+ createdAt: Date = M5Fixture.epoch+ ) {+ self.id = id+ self.displayTitle = displayTitle+ self.hostname = hostname+ self.type = type+ self.titleProvenance = titleProvenance+ self.lastParsedTitle = lastParsedTitle+ self.genericNotes = genericNotes+ self.workURLString = workURLString+ self.urlIdentity = urlIdentity+ self.createdAt = createdAt+ }+}++/// One Entry **row**. Two rows sharing `id` are one split (or torn) group;+/// giving them different `workID`s is the duplicate-set-sibling shape Q25 is+/// about.+struct M5SeedEntry: Sendable {+ var id: UUID+ var captureTitle: String+ var hostname: String+ var path: String+ var note: String = ""+ var rating: Rating?+ var chapterTitle: String?+ var firstCapturedAt: Date = M5Fixture.epoch+ var lastSharedAt: Date = M5Fixture.epoch+ var workID: UUID?+ /// Which row of `workID`'s group this row points at, in seeding order.+ var workRowIndex: Int = 0+ var workAssignmentProvenance: FieldProvenanceKind = .manual+ var intentionallyUnattached: Bool = false++ init(+ id: UUID, captureTitle: String, hostname: String, path: String, note: String = "",+ rating: Rating? = nil, chapterTitle: String? = nil,+ firstCapturedAt: Date = M5Fixture.epoch, lastSharedAt: Date = M5Fixture.epoch,+ workID: UUID? = nil, workRowIndex: Int = 0,+ workAssignmentProvenance: FieldProvenanceKind = .manual,+ intentionallyUnattached: Bool = false+ ) {+ self.id = id+ self.captureTitle = captureTitle+ self.hostname = hostname+ self.path = path+ self.note = note+ self.rating = rating+ self.chapterTitle = chapterTitle+ self.firstCapturedAt = firstCapturedAt+ self.lastSharedAt = lastSharedAt+ self.workID = workID+ self.workRowIndex = workRowIndex+ self.workAssignmentProvenance = workAssignmentProvenance+ self.intentionallyUnattached = intentionallyUnattached+ }+}++extension LibraryRepository {++ /// Writes the given rows into an empty (or not) store in one save.+ func seedM5Rows(+ sites: [M5SeedSite] = [], works: [M5SeedWork] = [], entries: [M5SeedEntry] = []+ ) async throws {+ try await withLockedContext(mode: .exclusive, operation: "seeding M5 test rows") { context in+ var siteRows: [String: Site] = [:]+ for seed in sites {+ let site = Site(hostname: seed.hostname, displayName: seed.displayName)+ site.mode = seed.mode+ if let example = seed.junkExample {+ site.junkSuffixRule = try ArticleTitleCleaner.deriveRule(+ from: example, suffixSegmentCount: seed.junkSuffixSegments)+ }+ context.insert(site)+ if seed.wholeTitleRule {+ let pattern = try TitlePattern(+ id: UUID(), version: 1, isActive: true, createdAt: M5Fixture.epoch,+ definition: .wholeTitle, site: site)+ pattern.trimPrefix = seed.trimPrefix+ pattern.trimSuffix = seed.trimSuffix+ context.insert(pattern)+ }+ // Last row wins as the *seeding* index only; production winner+ // selection is `SiteResolutionOrder`'s and is never this map.+ siteRows[seed.hostname] = site+ }++ var workRows: [UUID: [Work]] = [:]+ for seed in works {+ let work = Work(+ id: seed.id, displayTitle: seed.displayTitle, siteHostname: seed.hostname,+ timestamp: seed.createdAt)+ work.type = seed.type+ work.titleProvenance = seed.titleProvenance+ work.lastParsedTitle = seed.lastParsedTitle+ work.genericNotes = seed.genericNotes+ work.workURLString = seed.workURLString+ work.urlIdentity = seed.urlIdentity+ if seed.urlIdentity != nil { work.urlIdentityState = .legacyUnverified }+ context.insert(work)+ work.site = siteRows[seed.hostname]+ workRows[seed.id, default: []].append(work)+ }++ for seed in entries {+ let url = "https://\(seed.hostname)/\(seed.path)"+ let entry = Entry(+ id: seed.id, captureTitle: seed.captureTitle, captureTitleSource: .manual,+ rawURLString: url, hostname: seed.hostname, entryIdentityKey: url,+ timestamp: seed.firstCapturedAt, note: seed.note, rating: seed.rating)+ entry.conservativeIdentityKey = url+ entry.firstCapturedAt = seed.firstCapturedAt+ entry.lastSharedAt = seed.lastSharedAt+ entry.modifiedAt = seed.lastSharedAt+ entry.chapterTitle = seed.chapterTitle+ if seed.chapterTitle != nil { entry.chapterTitleProvenance = .manual }+ entry.intentionallyUnattached = seed.intentionallyUnattached+ context.insert(entry)+ // Rows this batch did not insert are resolved from the store, so+ // a second seeding call can attach to an existing Site or Work —+ // which is how a suite models a record arriving mid-flow.+ if let site = siteRows[seed.hostname] {+ entry.site = site+ } else {+ entry.site = try LibraryRepository.fetchSites(+ hostname: seed.hostname, context: context).first+ }+ if let workID = seed.workID {+ let rows = try workRows[workID]+ ?? GroupOrdering.sortedWorkRows(+ context.fetch(+ FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID })))+ entry.work = rows[seed.workRowIndex]+ entry.workAssignmentProvenance = seed.workAssignmentProvenance+ }+ }+ try context.save()+ }+ }+}++extension LibraryRepository {+ /// Re-runs the open-time validation over the seeded rows and republishes the+ /// quarantine and diagnoses from it.+ ///+ /// `seedM5Rows` writes *after* the open, so a hostname it seeds a diagnosis+ /// onto is not in the in-memory map — and a suite about what a commit does+ /// with a **recorded** prior diagnosis would silently be testing the+ /// no-diagnosis case instead. One derivation, the bootstrap's.+ func m5RepublishDiagnoses() async throws {+ let validated = try await withLockedContext(+ mode: .shared, operation: "validating seeded M5 rows"+ ) { context in+ try V4LibraryValidator.validate(context: context)+ }+ diagnostics = validated+ setQuarantine(validated.quarantineMap())+ }+}++/// The row-level fields `moveEntry`'s "Leave unattached" branch writes, as a+/// comparable value. `modifiedAt` is deliberately absent: two writes at two+/// clock readings differ there and agree about everything that matters.+struct M5UnattachmentFields: Sendable, Equatable {+ var hasWork: Bool+ var intentionallyUnattached: Bool+ var assignmentProvenance: FieldProvenanceKind+ var hasWorkPatternID: Bool+ var hasWorkPatternVersion: Bool+}++extension LibraryRepository {+ /// Read raw, because the snapshot deliberately does not carry the pattern+ /// citation the unattachment clears.+ func m5UnattachmentFields(of id: UUID) async throws -> M5UnattachmentFields {+ try await withLockedContext(mode: .shared, operation: "reading unattachment fields") {+ context in+ let rows = try context.fetch(+ FetchDescriptor<Entry>(predicate: #Predicate { $0.id == id }))+ guard let row = rows.first else {+ throw LibraryRepositoryError.recordNotFound(type: "Entry", id: id)+ }+ return M5UnattachmentFields(+ hasWork: row.work != nil,+ intentionallyUnattached: row.intentionallyUnattached,+ assignmentProvenance: row.workAssignmentProvenance,+ hasWorkPatternID: row.workPatternID != nil,+ hasWorkPatternVersion: row.workPatternVersion != nil)+ }+ }++ /// How many rows share one Entry application UUID — the thing a repoint must+ /// leave alone and a nullify would tear.+ func m5RowCount(ofEntry id: UUID) async throws -> Int {+ try await withLockedContext(mode: .shared, operation: "counting Entry rows") { context in+ try context.fetchCount(FetchDescriptor<Entry>(predicate: #Predicate { $0.id == id }))+ }+ }++ /// The display name each hostname's **winning** Site row carries, read the+ /// way `sites()` has to resolve it. Asserting against this is asserting the+ /// wiring rather than re-deriving the winner rule in a second place.+ func m5SiteWinnerNames() async throws -> [String: String] {+ try await withLockedContext(mode: .shared, operation: "reading Site winners") { context in+ SiteResolutionOrder+ .winnersByHostname(try context.fetch(FetchDescriptor<Site>()))+ .mapValues(\.displayName)+ }+ }+}++/// A repository over a fresh on-disk store, torn down with the test.+final class M5Fixture {+ static let epoch = Date(timeIntervalSince1970: 1_800_000_000)++ let directory: URL+ let repository: LibraryRepository++ init(clock: any RepositoryClock = FixedRepositoryClock(M5Fixture.epoch)) async throws {+ directory = FileManager.default.temporaryDirectory+ .appending(path: "AsterismM5Tests-\(UUID())", directoryHint: .isDirectory)+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+ repository = try await LibraryRepository.open(+ LibraryConfiguration(rootDirectory: directory), clock: clock,+ saveStrategy: ModelContextSaveStrategy())+ }++ deinit { try? FileManager.default.removeItem(at: directory) }+}++/// A date whose calendar day is fixed in the device's own time zone, so the+/// locale-formatted date text a golden asserts cannot move with the runner's+/// zone.+func m5LocalDate(year: Int, month: Int, day: Int) throws -> Date {+ var components = DateComponents()+ components.year = year+ components.month = month+ components.day = day+ components.hour = 12+ return try #require(Calendar.current.date(from: components))+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkdownExportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkdownExportTests.swiftnew file mode 100644index 0000000..7c191dc--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkdownExportTests.swift@@ -0,0 +1,355 @@+import Foundation+import Testing++@testable import AsterismCore++// Golden strings for the pure renderer (Reqs 1, 2). Nothing here touches the+// store or the clock: the input structs carry pre-formatted date text, so the+// renderer is a total string function and every case below is readable as the+// document a reader would get.++@Suite("Markdown export rendering")+struct MarkdownExportTests {++ // MARK: - Entry blocks (Req 1)++ @Test("A rated, attached entry renders heading, work line, date and note as distinct paragraphs")+ func ratedEntryBlock() {+ let rendered = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "Chapter 14", rawURLString: "https://example.com/ch14",+ workTitle: "The Perfect Run", workTypeLabel: "novel",+ dateText: "12 May 2026", rating: .up, note: "Loved it."))+ #expect(rendered == """+ ## [Chapter 14](https://example.com/ch14)++ *The Perfect Run* (novel)++ ▲ · 12 May 2026++ Loved it.++ """)+ }++ @Test("A down rating renders its glyph; an unrated entry renders the date alone")+ func ratingGlyphs() {+ let down = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "Chapter 2", rawURLString: "https://example.com/2",+ dateText: "13 May 2026", rating: .down, note: "Dragged."))+ #expect(down.contains("\n▼ · 13 May 2026\n"))++ let unrated = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "Chapter 3", rawURLString: "https://example.com/3",+ dateText: "14 May 2026", rating: nil, note: "Fine."))+ #expect(unrated.contains("\n14 May 2026\n"))+ #expect(!unrated.contains("▲"))+ #expect(!unrated.contains("▼"))+ }++ @Test("An empty note leaves heading and date line with no placeholder (1.4)")+ func emptyNote() {+ let rendered = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "Chapter 9", rawURLString: "https://example.com/9",+ dateText: "12 May 2026", rating: nil, note: " "))+ #expect(rendered == """+ ## [Chapter 9](https://example.com/9)++ 12 May 2026++ """)+ }++ @Test("The work line is omitted for an unattached entry, an .other type, and an empty title (1.9)")+ func workLineOmissions() {+ let unattached = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "Chapter 1", rawURLString: "https://example.com/1",+ workTitle: nil, workTypeLabel: nil, dateText: "12 May 2026",+ rating: nil, note: ""))+ #expect(unattached == """+ ## [Chapter 1](https://example.com/1)++ 12 May 2026++ """)++ // `.other` reaches the renderer as a nil label, so the title stands alone.+ let untyped = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "Chapter 1", rawURLString: "https://example.com/1",+ workTitle: "A Serial", workTypeLabel: nil, dateText: "12 May 2026",+ rating: nil, note: ""))+ #expect(untyped == """+ ## [Chapter 1](https://example.com/1)++ *A Serial*++ 12 May 2026++ """)++ // A title that is only whitespace names no work, so no line renders.+ let blankTitle = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "Chapter 1", rawURLString: "https://example.com/1",+ workTitle: " \n ", workTypeLabel: "novel", dateText: "12 May 2026",+ rating: nil, note: ""))+ #expect(!blankTitle.contains("*"))+ #expect(!blankTitle.contains("novel"))+ }++ @Test("An entry block carries nothing beyond heading link, work line, rating and date (1.6)")+ func noExtraMetadata() {+ let rendered = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "Chapter 14", rawURLString: "https://example.com/ch14",+ workTitle: "The Perfect Run", workTypeLabel: "novel",+ dateText: "12 May 2026", rating: .up, note: "Loved it."))+ let lines = rendered.split(separator: "\n", omittingEmptySubsequences: true)+ #expect(lines.count == 4)+ }++ // MARK: - Escaping and encoding (1.7, 2.6)++ @Test("Adversarial title text renders as its literal text in the heading")+ func headingEscaping() {+ let cases: [(title: String, expected: String)] = [+ ("Square [brackets]", #"Square \[brackets\]"#),+ ("Stars *and* more", #"Stars \*and\* more"#),+ ("Under_scores_here", #"Under\_scores\_here"#),+ ("Back`ticks`", #"Back\`ticks\`"#),+ ("#Hashed", "\\#Hashed"),+ ("Angle <brackets>", #"Angle \<brackets\>"#),+ (#"A back\slash"#, #"A back\\slash"#),+ (" Surrounded ", "Surrounded"),+ ("Internal\nnewline", "Internal newline"),+ ("Blank\n\nline", "Blank line"),+ ("Emoji 🚀 stays", "Emoji 🚀 stays"),+ ("مرحبا بالعالم", "مرحبا بالعالم"),+ ]+ for (title, expected) in cases {+ let rendered = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: title, rawURLString: "https://example.com/x",+ dateText: "12 May 2026", rating: nil, note: ""))+ let heading = rendered.split(separator: "\n", omittingEmptySubsequences: false).first+ #expect(heading == "## [\(expected)](https://example.com/x)", "title: \(title)")+ }+ }++ @Test("The work line escapes its title the same way and stays its own paragraph")+ func workLineEscaping() {+ let rendered = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "Chapter 1", rawURLString: "https://example.com/1",+ workTitle: " A *Starred*\nSerial ", workTypeLabel: "novel",+ dateText: "12 May 2026", rating: .up, note: ""))+ #expect(rendered == """+ ## [Chapter 1](https://example.com/1)++ *A \\*Starred\\* Serial* (novel)++ ▲ · 12 May 2026++ """)+ }++ @Test("Link targets percent-encode spaces and parentheses, and nothing else")+ func urlEncoding() {+ let rendered = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "Chapter", rawURLString: "https://example.com/a (b)/c d?x=1&y=2",+ dateText: "12 May 2026", rating: nil, note: ""))+ #expect(rendered.hasPrefix(+ "## [Chapter](https://example.com/a%20%28b%29/c%20d?x=1&y=2)"))+ }++ @Test("A heading with no text falls back to the entry's URL so the link survives")+ func blankHeadingFallback() {+ let rendered = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: " \n ", rawURLString: "https://example.com/x",+ dateText: "12 May 2026", rating: nil, note: ""))+ #expect(rendered.hasPrefix("## [https://example.com/x](https://example.com/x)"))++ // The fallback text is trimmed and collapsed like every other+ // title-bearing value, so junk whitespace on a synced raw URL cannot+ // split the link text across lines.+ let noisy = MarkdownExport.renderEntry(+ EntryExportInput(+ headingText: "", rawURLString: " https://example.com/x ",+ dateText: "12 May 2026", rating: nil, note: ""))+ #expect(noisy.hasPrefix("## [https://example.com/x]("))+ }++ // MARK: - Work documents (Req 2)++ @Test("A work document assembles title, linked site line, generic notes and blocks (2.1, 2.2)")+ func workDocument() {+ let rendered = MarkdownExport.renderWork(+ WorkExportInput(+ titleText: "The Perfect Run", siteName: "Royal Road",+ workURLString: "https://example.com/work (1)",+ genericNotes: "Re-reading this one.",+ blocks: [+ EntryExportInput(+ headingText: "Chapter 1", rawURLString: "https://example.com/1",+ dateText: "12 May 2026", rating: .up, note: "Strong opening."),+ EntryExportInput(+ headingText: "Chapter 2", rawURLString: "https://example.com/2",+ dateText: "13 May 2026", rating: nil, note: ""),+ ]))+ #expect(rendered == """+ # The Perfect Run++ [Royal Road](https://example.com/work%20%281%29)++ Re-reading this one.++ ## [Chapter 1](https://example.com/1)++ ▲ · 12 May 2026++ Strong opening.++ ## [Chapter 2](https://example.com/2)++ 13 May 2026++ """)+ }++ @Test("Without a confirmed work URL the site name renders unlinked (2.2)")+ func unlinkedSiteLine() {+ let rendered = MarkdownExport.renderWork(+ WorkExportInput(+ titleText: "A Serial", siteName: "example.com", workURLString: nil,+ genericNotes: "", blocks: []))+ #expect(rendered == """+ # A Serial++ example.com++ """)+ }++ @Test("A work with no entries renders title, site line and notes only (2.1)")+ func emptyWork() {+ let rendered = MarkdownExport.renderWork(+ WorkExportInput(+ titleText: "A Serial", siteName: "example.com",+ workURLString: "https://example.com/serial", genericNotes: "Notes.",+ blocks: []))+ #expect(rendered == """+ # A Serial++ [example.com](https://example.com/serial)++ Notes.++ """)+ }++ @Test("Blocks inside a work document never render a work line, even if one is carried (1.9)")+ func embeddedBlocksOmitTheWorkLine() {+ let rendered = MarkdownExport.renderWork(+ WorkExportInput(+ titleText: "A Serial", siteName: "example.com", workURLString: nil,+ genericNotes: "",+ blocks: [+ EntryExportInput(+ headingText: "Chapter 1", rawURLString: "https://example.com/1",+ workTitle: "A Serial", workTypeLabel: "novel",+ dateText: "12 May 2026", rating: nil, note: ""),+ ]))+ #expect(!rendered.contains("*A Serial*"))+ #expect(!rendered.contains("novel"))+ }++ @Test("A blank work title omits the H1, and a blank site name omits the site line")+ func workDocumentOmissions() {+ // Both write paths refuse a blank title, so this is synced junk only:+ // the site line and the blocks still carry the document.+ let untitled = MarkdownExport.renderWork(+ WorkExportInput(+ titleText: " \n ", siteName: "example.com", workURLString: nil,+ genericNotes: "", blocks: []))+ #expect(untitled == """+ example.com++ """)++ // Nothing names the site and no URL to hang it on, so no line renders —+ // rather than an empty paragraph the reader has to delete.+ let siteless = MarkdownExport.renderWork(+ WorkExportInput(+ titleText: "A Serial", siteName: " ", workURLString: nil,+ genericNotes: "Notes.", blocks: []))+ #expect(siteless == """+ # A Serial++ Notes.++ """)+ }++ @Test("H1 and site-line text are escaped and collapsed like every other title (2.6)")+ func workDocumentEscaping() {+ let rendered = MarkdownExport.renderWork(+ WorkExportInput(+ titleText: " A [Bracketed]\nSerial ", siteName: "site_with_underscores",+ workURLString: nil, genericNotes: "Notes with *stars* stay verbatim.",+ blocks: []))+ #expect(rendered == """+ # A \\[Bracketed\\] Serial++ site\\_with\\_underscores++ Notes with *stars* stay verbatim.++ """)+ }++ // MARK: - Filenames (1.5, 2.4)++ @Test("Filenames sanitise the title, cap at 80 characters, and fall back per kind")+ func filenames() {+ #expect(+ MarkdownExport.filename(+ for: EntryExportInput(+ headingText: " Chapter 14: The/Long\\Way? ",+ rawURLString: "https://example.com/x", dateText: "12 May 2026"))+ == "Chapter 14 TheLongWay.md")++ #expect(+ MarkdownExport.filename(+ for: WorkExportInput(+ titleText: "100%*Pure*|Serial\"Title\"<>", siteName: "example.com",+ workURLString: nil, genericNotes: "", blocks: []))+ == "100PureSerialTitle.md")++ let long = String(repeating: "a", count: 200)+ let capped = MarkdownExport.filename(+ for: WorkExportInput(+ titleText: long, siteName: "example.com", workURLString: nil,+ genericNotes: "", blocks: []))+ #expect(capped == String(repeating: "a", count: 80) + ".md")++ #expect(+ MarkdownExport.filename(+ for: EntryExportInput(+ headingText: " /// ", rawURLString: "https://example.com/x",+ dateText: "12 May 2026"))+ == "Entry.md")+ #expect(+ MarkdownExport.filename(+ for: WorkExportInput(+ titleText: " ", siteName: "example.com", workURLString: nil,+ genericNotes: "", blocks: []))+ == "Work.md")+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swiftindex 53315ce..16e75d3 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift@@ -192,13 +192,6 @@ struct ReShareExtensionUITests { let label = ReShareActionLabels.primaryActionAccessibilityLabel(for: fixture.viewModel.lookupState) #expect(label == "Update existing entry") }-- // MARK: - Minimum hit target (44pt)-- @Test("Hit target constants are at least 44 points")- func hitTargetMinimum() {- #expect(ReShareLayoutConstants.minimumHitTarget >= 44)- } } // MARK: - Test Fixture
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SitesListReadTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SitesListReadTests.swiftnew file mode 100644index 0000000..76b277e--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SitesListReadTests.swift@@ -0,0 +1,69 @@+import Foundation+import Testing++@testable import AsterismCore++// The Sites settings screen's read (Req 6.1, Q10, Q24): every stored site, one+// row per hostname, whatever mode it is in.++@Suite("Sites list read", .serialized)+struct SitesListReadTests {++ @Test("An empty library lists no sites")+ func emptyStore() async throws {+ let fixture = try await M5Fixture()+ #expect(try await fixture.repository.sites().isEmpty)+ }++ @Test("Every stored site appears once per hostname, in all three modes (6.1)")+ func allModesOneRowPerHostname() async throws {+ let fixture = try await M5Fixture()+ try await fixture.repository.seedM5Rows(sites: [+ M5SeedSite(hostname: "untaught.test", displayName: "Alpha Site"),+ M5SeedSite(+ hostname: "taught.test", displayName: "Middle Site", mode: .taught,+ wholeTitleRule: true),+ M5SeedSite(+ hostname: "articles.test", displayName: "Zulu Site", mode: .articles,+ junkExample: "Story — Zulu Site"),+ ])++ let sites = try await fixture.repository.sites()+ #expect(sites.map(\.hostname) == ["untaught.test", "taught.test", "articles.test"])+ #expect(sites.map(\.mode) == [.untaught, .taught, .articles])+ #expect(sites.map(\.displayName) == ["Alpha Site", "Middle Site", "Zulu Site"])+ // The taught row's active pattern is disclosed; the articles row's junk+ // rule is what its screen needs to describe the mode.+ #expect(sites[0].patternIDs.isEmpty)+ #expect(sites[1].patternIDs.count == 1)+ #expect(sites[2].junkSuffixRule != nil)+ }++ @Test("Duplicate Site rows resolve to the hostname's winner, not to two rows (Q24)")+ func duplicateRowsResolveToWinner() async throws {+ let fixture = try await M5Fixture()+ try await fixture.repository.seedM5Rows(sites: [+ M5SeedSite(hostname: "dup.test", displayName: "Duplicated One"),+ M5SeedSite(hostname: "dup.test", displayName: "Duplicated Two"),+ M5SeedSite(hostname: "single.test", displayName: "Alone"),+ ])++ let sites = try await fixture.repository.sites()+ #expect(sites.count == 2)+ let duplicated = try #require(sites.first { $0.hostname == "dup.test" })+ let winners = try await fixture.repository.m5SiteWinnerNames()+ #expect(duplicated.displayName == winners["dup.test"])+ }++ @Test("Rows sort by display name, with the hostname breaking ties")+ func sortedByDisplayName() async throws {+ let fixture = try await M5Fixture()+ try await fixture.repository.seedM5Rows(sites: [+ M5SeedSite(hostname: "b.test", displayName: "Shared Name"),+ M5SeedSite(hostname: "a.test", displayName: "Shared Name"),+ M5SeedSite(hostname: "c.test", displayName: "Another Name"),+ ])+ let sites = try await fixture.repository.sites()+ #expect(sites.map(\.hostname) == ["c.test", "a.test", "b.test"])+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDeletionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDeletionTests.swiftnew file mode 100644index 0000000..98643db--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDeletionTests.swift@@ -0,0 +1,471 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// Work deletion (Req 7): a project/commit pair with the M4c write discipline —+// whole-group addressing, fresh-context re-verification, a disclosure gate on+// both dispositions, and a validate-before-save that rolls back rather than+// persisting damage.++@Suite("Work deletion", .serialized)+struct WorkDeletionTests {++ // MARK: - Dispositions++ @Test("Delete-entries-too removes the work and every entry of it (7.2)")+ func deleteEntriesDisposition() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+ entries: [+ M5SeedEntry(+ id: UUID(), captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ workID: workID),+ M5SeedEntry(+ id: UUID(), captureTitle: "Chapter 2", hostname: "example.com", path: "2",+ workID: workID),+ ])++ let contract = try await fixture.repository.projectWorkDeletion(workID: workID)+ #expect(contract.entryGroupIDs.count == 2)+ #expect(contract.tornEntryVariants.isEmpty)++ let outcome = try await fixture.repository.commitWorkDeletion(+ contract, disposition: .deleteEntries, disclosedVariants: nil)+ #expect(outcome == .committed)++ let counts = try await fixture.repository.recordCounts()+ #expect(counts.works == 0)+ #expect(counts.entries == 0)+ }++ @Test("Detach writes exactly moveEntry's leave-unattached fields (7.3, 7.4, Q22)")+ func detachDispositionMatchesMoveEntry() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let otherWorkID = UUID()+ let detached = UUID()+ let moved = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [+ M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com"),+ M5SeedWork(id: otherWorkID, displayTitle: "Another", hostname: "example.com"),+ ],+ entries: [+ M5SeedEntry(+ id: detached, captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ note: "Kept", rating: .up, workID: workID),+ M5SeedEntry(+ id: moved, captureTitle: "Chapter 2", hostname: "example.com", path: "2",+ workID: otherWorkID),+ ])++ let contract = try await fixture.repository.projectWorkDeletion(workID: workID)+ let outcome = try await fixture.repository.commitWorkDeletion(+ contract, disposition: .detachEntries, disclosedVariants: nil)+ #expect(outcome == .committed)++ // The reader's content survives; the work does not.+ let entry = try await fixture.repository.entry(id: detached)+ #expect(entry.note == "Kept")+ #expect(entry.rating == .up)+ #expect(entry.workID == nil)+ #expect(entry.intentionallyUnattached)+ let works = try await fixture.repository.works()+ #expect(works.works.map(\.id) == [otherWorkID])+ #expect(works.unattachedEntries.map(\.id) == [detached])++ // One set of unattachment semantics in the codebase: the same six fields+ // Move to…'s "Leave unattached" branch writes.+ try await fixture.repository.moveEntry(moved, to: .unattached)+ #expect(+ try await fixture.repository.m5UnattachmentFields(of: detached)+ == fixture.repository.m5UnattachmentFields(of: moved))+ }++ /// Regression, found by `WorkDetailActionsUITests` on the composed fixture:+ /// detaching a Work whose entries a **URL rule** assigned rolled the whole+ /// deletion back. The disposition wrote `.manual` provenance while leaving+ /// the URL-assignment trio in place, which the V4 tuple table refuses+ /// ("manual assignment has incompatible relationship or provenance"), so+ /// step 7's validator refused the graph it had just built. Every entry on a+ /// URL-taught site carries that trio, so no such work could be detached at+ /// all — and the M5 seeder only ever wrote pattern or manual assignments,+ /// which is why the suite above did not see it.+ @Test("Detaching entries a URL rule assigned commits and clears the URL trio (7.3)")+ func detachClearsURLAssignmentReferences() async throws {+ let fixture = try ComposedRepoFixture()+ try fixture.seedUntaught([+ (UUID(), "Chapter 7 - Real Work", "https://ex.com/read?id=42&chapter=7"),+ (UUID(), "Chapter 8 - Real Work", "https://ex.com/read?id=42&chapter=8"),+ ])+ let request = ComposedTeachingRequest(+ titleDefinition: .segment(+ work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: []),+ urlDefinition: .workAndSequence(+ work: URLFieldSelector(locator: .query(name: ExactScalarString("id"))),+ sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter")))))+ let teaching = try await fixture.repository.projectComposedTeaching(+ hostname: "ex.com", request: request)+ guard case .committed = try await fixture.repository.commitComposedTeaching(teaching) else {+ Issue.record("expected the composed teach to commit")+ return+ }++ let workID = try #require(try await fixture.repository.works().works.first?.id)+ let contract = try await fixture.repository.projectWorkDeletion(workID: workID)+ #expect(+ try await fixture.repository.commitWorkDeletion(+ contract, disposition: .detachEntries, disclosedVariants: nil) == .committed)++ let context = fixture.freshContext()+ #expect(try V4LibraryValidator.validate(context: context).tupleDiagnoses["ex.com"] == nil)+ for entry in try context.fetch(FetchDescriptor<Entry>()) {+ #expect(entry.work == nil)+ #expect(entry.intentionallyUnattached)+ #expect(entry.workAssignmentProvenance == .manual)+ #expect(entry.workURLRuleID == nil)+ #expect(entry.workURLRuleVersion == nil)+ #expect(entry.workURLAssignmentKindRaw == nil)+ }+ }++ @Test("A detached entry stays unattached through a later re-parse (7.3, §2.6)")+ func detachSurvivesReparse() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let detached = UUID()+ let untouched = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+ entries: [+ M5SeedEntry(+ id: detached, captureTitle: "Chapter 7 - A Serial", hostname: "example.com",+ path: "7", workID: workID),+ M5SeedEntry(+ id: untouched, captureTitle: "Chapter 8 - A Serial", hostname: "example.com",+ path: "8"),+ ])++ let contract = try await fixture.repository.projectWorkDeletion(workID: workID)+ #expect(+ try await fixture.repository.commitWorkDeletion(+ contract, disposition: .detachEntries, disclosedVariants: nil) == .committed)++ // Teaching the hostname re-derives every assignment on it. The entry the+ // reader detached is authored state, and re-parsing never overrides it.+ let request = ComposedTeachingRequest(+ titleDefinition: .segment(+ work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: []),+ acknowledgeUnsettled: true)+ let teaching = try await fixture.repository.projectComposedTeaching(+ hostname: "example.com", request: request)+ let taught = try await fixture.repository.commitComposedTeaching(teaching)+ guard case .committed = taught else {+ Issue.record("expected the teach to commit, got \(taught)")+ return+ }++ #expect(try await fixture.repository.entry(id: detached).workID == nil)+ #expect(try await fixture.repository.entry(id: detached).intentionallyUnattached)+ // The entry that was never detached does get assigned, so the re-parse+ // demonstrably ran.+ #expect(try await fixture.repository.entry(id: untouched).workID != nil)+ }++ // MARK: - Refusals++ @Test("A torn work group refuses both dispositions (Req 2.8)")+ func tornWorkGroupRefuses() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [+ M5SeedWork(id: workID, displayTitle: "One Title", hostname: "example.com"),+ M5SeedWork(id: workID, displayTitle: "Another Title", hostname: "example.com"),+ ])++ let contract = try await fixture.repository.projectWorkDeletion(workID: workID)+ for disposition in [WorkDeletionDisposition.deleteEntries, .detachEntries] {+ let outcome = try await fixture.repository.commitWorkDeletion(+ contract, disposition: disposition, disclosedVariants: nil)+ guard case .conflict(.torn(let recordID, _)) = outcome else {+ Issue.record("expected a torn conflict, got \(outcome)")+ return+ }+ #expect(recordID == workID)+ }+ #expect(try await fixture.repository.recordCounts().works == 1)+ }++ @Test("A torn entry group gates both dispositions on a fresh disclosure (7.2, Req 2.9)")+ func tornEntryDisclosureGate() async throws {+ for disposition in [WorkDeletionDisposition.deleteEntries, .detachEntries] {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let torn = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+ entries: [+ M5SeedEntry(+ id: torn, captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ note: "What this device wrote", workID: workID),+ M5SeedEntry(+ id: torn, captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ note: "What the other one wrote", workID: workID),+ ])++ let contract = try await fixture.repository.projectWorkDeletion(workID: workID)+ #expect(contract.tornEntryVariants[torn]?.count == 2)++ // No disclosure was made, so nothing authorises taking the unseen+ // variant away — on either disposition, because detaching authors+ // over it just as deleting destroys it.+ let undisclosed = try await fixture.repository.commitWorkDeletion(+ contract, disposition: disposition, disclosedVariants: nil)+ guard case .conflict(.torn(let recordID, _)) = undisclosed else {+ Issue.record("expected a torn conflict, got \(undisclosed)")+ return+ }+ #expect(recordID == torn)++ let stale = try await fixture.repository.commitWorkDeletion(+ contract, disposition: disposition,+ disclosedVariants: [VariantID(rawValue: "not a variant of this group")])+ guard case .conflict(.disclosureStale(let staleID, _)) = stale else {+ Issue.record("expected a stale disclosure, got \(stale)")+ return+ }+ #expect(staleID == torn)+ #expect(try await fixture.repository.recordCounts().works == 1)++ // The disclosure the reader actually saw, re-verified against the+ // fresh context, commits.+ #expect(+ try await fixture.repository.commitWorkDeletion(+ contract, disposition: disposition,+ disclosedVariants: contract.disclosableVariantIDs) == .committed)+ }+ }++ @Test("An entry group arriving between projection and commit refreshes the contract (Q28)")+ func entryGroupDriftRefreshes() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let arrived = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+ entries: [+ M5SeedEntry(+ id: UUID(), captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ workID: workID)+ ])+ let contract = try await fixture.repository.projectWorkDeletion(workID: workID)++ // A capture, or a row arriving from sync, after the dialog named a count.+ try await fixture.repository.seedM5Rows(+ entries: [+ M5SeedEntry(+ id: arrived, captureTitle: "Chapter 2", hostname: "example.com", path: "2",+ workID: workID)+ ])++ let outcome = try await fixture.repository.commitWorkDeletion(+ contract, disposition: .deleteEntries, disclosedVariants: nil)+ guard case .refreshed(let fresh) = outcome else {+ Issue.record("expected a refreshed contract, got \(outcome)")+ return+ }+ #expect(fresh.entryGroupIDs.count == 2)+ #expect(fresh.entryGroupIDs.contains(arrived))+ #expect(try await fixture.repository.recordCounts().works == 1)+ }++ @Test("A work already gone commits: a deletion that finds nothing has done its job")+ func goneTargetCommits() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")])++ let contract = try await fixture.repository.projectWorkDeletion(workID: workID)+ #expect(+ try await fixture.repository.commitWorkDeletion(+ contract, disposition: .deleteEntries, disclosedVariants: nil) == .committed)+ // The same contract again — the reader's second tap, or a peer device+ // that got there first.+ #expect(+ try await fixture.repository.commitWorkDeletion(+ contract, disposition: .deleteEntries, disclosedVariants: nil) == .committed)+ }++ // MARK: - Duplicate-set neighbours (7.6)++ @Test("An entry group shared with a duplicate-set sibling is repointed, not deleted (Q25)")+ func sharedEntryGroupRepoints() async throws {+ let fixture = try await M5Fixture()+ let deletedWork = UUID()+ let survivingWork = UUID()+ let shared = UUID()+ let owned = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [+ // One duplicate set: same hostname, same URL identity.+ M5SeedWork(+ id: deletedWork, displayTitle: "A Serial", hostname: "example.com",+ urlIdentity: "serial-1"),+ M5SeedWork(+ id: survivingWork, displayTitle: "A Serial", hostname: "example.com",+ urlIdentity: "serial-1"),+ ],+ entries: [+ // One logical record whose rows point at the two members. The+ // assignment normalisation reads it whole, not torn.+ M5SeedEntry(+ id: shared, captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ note: "Same note", workID: deletedWork),+ M5SeedEntry(+ id: shared, captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ note: "Same note", workID: survivingWork),+ M5SeedEntry(+ id: owned, captureTitle: "Chapter 2", hostname: "example.com", path: "2",+ workID: deletedWork),+ ])++ let contract = try await fixture.repository.projectWorkDeletion(workID: deletedWork)+ // Only the wholly-owned group is deleted or detached.+ #expect(contract.entryGroupIDs == [owned])++ #expect(+ try await fixture.repository.commitWorkDeletion(+ contract, disposition: .deleteEntries, disclosedVariants: nil) == .committed)++ // The shared record survives whole, untorn, attached to the survivor —+ // a nullify would have left one row pointing nowhere and torn it.+ let snapshot = try await fixture.repository.entry(id: shared)+ #expect(snapshot.workID == survivingWork)+ #expect(snapshot.note == "Same note")+ #expect(try await fixture.repository.m5RowCount(ofEntry: shared) == 2)+ let works = try await fixture.repository.works()+ #expect(works.works.map(\.id) == [survivingWork])+ #expect(works.works[0].entries.map(\.id) == [shared])+ }++ @Test("Deleting one member of a duplicate set re-derives the workload without it (7.6)")+ func duplicateWorkloadDropsTheDeletedMember() async throws {+ let fixture = try await M5Fixture()+ let deletedWork = UUID()+ let survivingWork = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [+ M5SeedWork(+ id: deletedWork, displayTitle: "A Serial", hostname: "example.com",+ genericNotes: "What this device wrote", urlIdentity: "serial-1"),+ M5SeedWork(+ id: survivingWork, displayTitle: "A Serial", hostname: "example.com",+ genericNotes: "What the other one wrote", urlIdentity: "serial-1"),+ ])++ let before = try await fixture.repository.recentPresentation(calendar: .current)+ let reviewed = try #require(before.duplicateWorkload.reviewItems.first)+ #expect(Set(reviewed.memberIDs) == [deletedWork, survivingWork])++ let contract = try await fixture.repository.projectWorkDeletion(workID: deletedWork)+ #expect(+ try await fixture.repository.commitWorkDeletion(+ contract, disposition: .deleteEntries, disclosedVariants: nil) == .committed)++ // Derived from live rows on every publication, never stored — so the+ // deleted member simply stops being referenced.+ let after = try await fixture.repository.recentPresentation(calendar: .current)+ #expect(after.duplicateWorkload.reviewItems.isEmpty)+ let workload = await fixture.repository.duplicateWorkload+ #expect(!workload.reviewItems.flatMap(\.memberIDs).contains(deletedWork))+ }++ // MARK: - Validation++ @Test("A diagnosis the deletion introduces rolls the write back (Q21)")+ func validatorRollback() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ // Taught with no active title rule: the tuple table's own illegal shape,+ // which the hostname-scoped validator reports the moment it looks.+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "tuple.test", mode: .taught)],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "tuple.test")])++ let contract = try await fixture.repository.projectWorkDeletion(workID: workID)+ let outcome = try await fixture.repository.commitWorkDeletion(+ contract, disposition: .deleteEntries, disclosedVariants: nil)+ guard case .invalidated = outcome else {+ Issue.record("expected an invalidated outcome, got \(outcome)")+ return+ }+ // Rolled back, not half-written.+ #expect(try await fixture.repository.recordCounts().works == 1)+ }++ @Test("A work on a hostname carrying a recorded diagnosis still deletes (Q34)")+ func recordedPriorDiagnosisCommits() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ // The same illegal tuple as the rollback case above, but **recorded** —+ // the state a library whose store already held it comes up in. The+ // deletion touches nothing the diagnosis is about, so the gate compares+ // an unchanged diagnosis and must not read it as one this write+ // introduced: a diagnosed hostname would otherwise have undeletable+ // works, which is the dead end Q34 exists to prevent.+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "tuple.test", mode: .taught)],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "tuple.test")])+ try await fixture.repository.m5RepublishDiagnoses()+ let prior = try #require(await fixture.repository.quarantineReason(hostname: "tuple.test"))++ let contract = try await fixture.repository.projectWorkDeletion(workID: workID)+ #expect(+ try await fixture.repository.commitWorkDeletion(+ contract, disposition: .deleteEntries, disclosedVariants: nil) == .committed)+ #expect(try await fixture.repository.recordCounts().works == 0)+ // Nothing repaired the tuple, so the quarantine survives the commit.+ #expect(await fixture.repository.quarantineReason(hostname: "tuple.test") == prior)+ }++ @Test("A deletion that removes the offending record clears the hostname's quarantine")+ func deletionClearsRepairedDiagnosis() async throws {+ let fixture = try await M5Fixture()+ let brokenWork = UUID()+ // A legal Site whose one Work carries a malformed confirmed URL: the+ // hostname is diagnosed, and deleting that Work is the repair.+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [+ M5SeedWork(+ id: brokenWork, displayTitle: "A Serial", hostname: "example.com",+ workURLString: "not a url")+ ])+ try await fixture.repository.m5RepublishDiagnoses()+ #expect(await fixture.repository.quarantineReason(hostname: "example.com") != nil)++ let contract = try await fixture.repository.projectWorkDeletion(workID: brokenWork)+ #expect(+ try await fixture.repository.commitWorkDeletion(+ contract, disposition: .deleteEntries, disclosedVariants: nil) == .committed)+ // The commit validated the hostname, so its answer is fresher than the+ // map: a deletion that repaired the state must not leave the quarantine+ // behind, the same way a teaching commit does not.+ #expect(await fixture.repository.quarantineReason(hostname: "example.com") == nil)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swiftnew file mode 100644index 0000000..1d41fc5--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift@@ -0,0 +1,121 @@+import Foundation+import Testing++@testable import AsterismCore++// The §6 Work-detail read (Reqs 5.2–5.4): counts over logical records, the+// newest note's URL, and chapter rows whose titles are cleaned by the repository+// rather than by the row that renders them.++@Suite("Work detail read", .serialized)+struct WorkDetailReadTests {++ @Test("The pulse counts logical records, and a split group counts once (5.2)")+ func pulseCountsLogicalRecords() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let split = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+ entries: [+ M5SeedEntry(+ id: UUID(), captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ rating: .up, workID: workID),+ M5SeedEntry(+ id: UUID(), captureTitle: "Chapter 2", hostname: "example.com", path: "2",+ rating: .down, workID: workID),+ // One record, two rows: one row, one count.+ M5SeedEntry(+ id: split, captureTitle: "Chapter 3", hostname: "example.com", path: "3",+ note: "Same on both", workID: workID),+ M5SeedEntry(+ id: split, captureTitle: "Chapter 3", hostname: "example.com", path: "3",+ note: "Same on both", workID: workID),+ ])++ let detail = try await fixture.repository.workDetail(id: workID)+ #expect(detail.work.id == workID)+ #expect(detail.pulse.notes == 3)+ #expect(detail.pulse.up == 1)+ #expect(detail.pulse.down == 1)+ #expect(detail.chapterRows.count == 3)+ }++ @Test("Chapter rows are newest-first and fall back to the cleaned capture title (5.4)")+ func chapterRowsOrderAndTitles() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let oldest = UUID()+ let middle = UUID()+ let newest = UUID()+ let day1 = try m5LocalDate(year: 2026, month: 5, day: 12)+ let day2 = try m5LocalDate(year: 2026, month: 5, day: 13)+ let day3 = try m5LocalDate(year: 2026, month: 5, day: 14)+ try await fixture.repository.seedM5Rows(+ sites: [+ M5SeedSite(+ hostname: "taught.test", mode: .taught, wholeTitleRule: true,+ trimPrefix: "Read: ")+ ],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "taught.test")],+ entries: [+ M5SeedEntry(+ id: oldest, captureTitle: "Read: Chapter One", hostname: "taught.test",+ path: "one", note: "First", rating: .up, firstCapturedAt: day1,+ lastSharedAt: day1, workID: workID),+ M5SeedEntry(+ id: middle, captureTitle: "Read: Chapter Two", hostname: "taught.test",+ path: "two", firstCapturedAt: day2, lastSharedAt: day2, workID: workID),+ M5SeedEntry(+ id: newest, captureTitle: "Read: Chapter Three", hostname: "taught.test",+ path: "three", chapterTitle: "The Long Way", firstCapturedAt: day3,+ lastSharedAt: day3, workID: workID),+ ])++ let detail = try await fixture.repository.workDetail(id: workID)+ #expect(detail.chapterRows.map(\.id) == [newest, middle, oldest])+ #expect(+ detail.chapterRows.map(\.displayTitle) == ["The Long Way", "Chapter Two", "Chapter One"])+ #expect(detail.chapterRows[2].note == "First")+ #expect(detail.chapterRows[2].rating == .up)+ #expect(detail.chapterRows[0].lastSharedAt == day3)+ }++ @Test("Open-last-noted points at the newest lastSharedAt entry's raw URL (5.3)")+ func lastNotedURL() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let day1 = try m5LocalDate(year: 2026, month: 5, day: 12)+ let day2 = try m5LocalDate(year: 2026, month: 5, day: 13)+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+ entries: [+ // Captured last, re-shared first: the action follows the share.+ M5SeedEntry(+ id: UUID(), captureTitle: "Chapter 1", hostname: "example.com", path: "1",+ firstCapturedAt: day1, lastSharedAt: day2, workID: workID),+ M5SeedEntry(+ id: UUID(), captureTitle: "Chapter 2", hostname: "example.com", path: "2",+ firstCapturedAt: day2, lastSharedAt: day1, workID: workID),+ ])++ let detail = try await fixture.repository.workDetail(id: workID)+ #expect(detail.lastNotedURLString == "https://example.com/1")+ }++ @Test("A work with no entries has no pulse, no rows and no last-noted URL (5.3)")+ func emptyWork() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "Empty", hostname: "example.com")])++ let detail = try await fixture.repository.workDetail(id: workID)+ #expect(detail.pulse == RatingPulse(notes: 0, up: 0, down: 0))+ #expect(detail.chapterRows.isEmpty)+ #expect(detail.lastNotedURLString == nil)+ }+}
diff --git a/Packages/AsterismCore/Tests/ConstellationKitTests/AsterismLayoutTests.swift b/Packages/AsterismCore/Tests/ConstellationKitTests/AsterismLayoutTests.swiftnew file mode 100644index 0000000..34831c9--- /dev/null+++ b/Packages/AsterismCore/Tests/ConstellationKitTests/AsterismLayoutTests.swift@@ -0,0 +1,28 @@+import CoreGraphics+import Testing++@testable import ConstellationKit++/// `AsterismLayout` is the one home for the shape and sizing constants.+///+/// The 44 pt minimum used to exist twice — here and as+/// `AsterismCore.ReShareLayoutConstants.minimumHitTarget`, which the share+/// extension's toolbar buttons read. Two constants for one rule is one+/// constant too many, so the duplicate went and its assertion came here.+@Suite("AsterismLayout tokens")+struct AsterismLayoutTests {+ @Test("the minimum hit target is at least 44 points")+ func hitTargetMinimum() {+ #expect(AsterismLayout.minHitTarget >= 44)+ }++ /// §6's chip family nests inside-out: chip 15 → URL chip 12 → tag 10–12.+ /// (The whole outside-in list is *not* monotonic — a button is a capsule at+ /// 21, larger than the 20 pt card it can sit in — so only the family that+ /// really nests is pinned.)+ @Test("the chip family's radii nest")+ func chipRadiiNest() {+ #expect(AsterismLayout.chipRadius >= AsterismLayout.urlChipRadius)+ #expect(AsterismLayout.urlChipRadius >= AsterismLayout.tagRadius)+ }+}
diff --git a/Packages/AsterismCore/Tests/ConstellationKitTests/SiteGlyphPaletteTests.swift b/Packages/AsterismCore/Tests/ConstellationKitTests/SiteGlyphPaletteTests.swiftnew file mode 100644index 0000000..a70b314--- /dev/null+++ b/Packages/AsterismCore/Tests/ConstellationKitTests/SiteGlyphPaletteTests.swift@@ -0,0 +1,88 @@+import Testing++@testable import ConstellationKit++/// `Site` has no color field in any schema version and M5 bumps no schema+/// (Q9), so a site glyph's color has to be a pure function of the hostname.+/// These tests pin that function: it must be stable across process runs and+/// across releases, because a glyph that changes hue between launches is not+/// an identity.+@Suite("SiteGlyphPalette hue derivation")+struct SiteGlyphPaletteTests {+ /// Golden values. FNV-1a (32-bit) over the lowercased hostname's UTF-8+ /// bytes, folded into degrees. Changing the hash or the fold changes every+ /// reader's glyph colors, so these are deliberately hard to update by+ /// accident.+ @Test(+ "known hostnames map to their pinned hues",+ arguments: [+ ("royalroad.com", 165.0),+ ("www.royalroad.com", 324.0),+ ("archiveofourown.org", 270.0),+ ("scribblehub.com", 39.0),+ ("novelupdates.com", 274.0),+ ("example.com", 278.0),+ ]+ )+ func goldenHues(hostname: String, expected: Double) {+ #expect(SiteGlyphPalette.hue(forHostname: hostname) == expected)+ }++ @Test("the same hostname always yields the same hue")+ func deterministic() {+ let first = SiteGlyphPalette.hue(forHostname: "royalroad.com")+ let second = SiteGlyphPalette.hue(forHostname: "royalroad.com")+ #expect(first == second)+ }++ /// Hostnames are case-insensitive; two spellings of one site must not read+ /// as two different sites.+ @Test("hue is case-insensitive")+ func caseInsensitive() {+ #expect(+ SiteGlyphPalette.hue(forHostname: "RoyalRoad.COM")+ == SiteGlyphPalette.hue(forHostname: "royalroad.com")+ )+ }++ /// Surrounding whitespace is normalised away for the same reason.+ @Test("hue ignores surrounding whitespace")+ func whitespaceInsensitive() {+ #expect(+ SiteGlyphPalette.hue(forHostname: " royalroad.com ")+ == SiteGlyphPalette.hue(forHostname: "royalroad.com")+ )+ }++ @Test("a sample of hostnames produces distinct hues")+ func distinctAcrossSample() {+ let hostnames = [+ "royalroad.com",+ "archiveofourown.org",+ "scribblehub.com",+ "novelupdates.com",+ "fanfiction.net",+ "wattpad.com",+ "webnovel.com",+ "spacebattles.com",+ ]+ let hues = Set(hostnames.map { SiteGlyphPalette.hue(forHostname: $0) })+ #expect(hues.count == hostnames.count)+ }++ @Test("hue stays inside the colour wheel", arguments: [+ "royalroad.com", "a", "", "ünïcödé.example", "very.long.subdomain.chain.example.org",+ ])+ func hueInRange(hostname: String) {+ let hue = SiteGlyphPalette.hue(forHostname: hostname)+ #expect(hue >= 0)+ #expect(hue < 360)+ }++ /// An empty hostname is not an error — a `?` glyph still needs a defined+ /// value, and the flat dim variant simply ignores it.+ @Test("the empty hostname has a defined hue")+ func emptyHostname() {+ #expect(SiteGlyphPalette.hue(forHostname: "") == 61.0)+ }+}
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex a3df516..ab01816 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -93,6 +93,28 @@ for i in 1 2 3; do make test-performance-m4 RUNS=1 > /tmp/m4-run$i.log 2>&1 || t `RUNS=<n>` is still the right flag on a target that is green; it is the abort that makes it useless here, not the loop. +## "Test crashed with signal kill/term" is usually the simulator, not the app++A UI test that reports `Test crashed with signal kill.` or `signal term.` with+**no assertion message** has almost certainly not failed — the simulator's+render server died under it. Symptoms: several unrelated tests in the same run+fail the same way, `xcodebuild` also reports `Mach error -308 (ipc/mig) server+died`, and `ls -t ~/Library/Logs/DiagnosticReports/SimRenderServer-*.ips` shows+one crash per test run.++Confirm it is the environment before hunting the code: `git stash` and re-run+the same test. If the baseline crashes too, it is not your change.++The fix that worked (2026-08-04, the `polish-and-export` visual pass) was to run+against a **different simulator model** — `make test-only SIMULATOR="iPhone 17"+TEST=…`. `simctl erase` on the wedged device did *not* help, and the same suite+was green on the other model within one run. `SIMULATOR` is a plain Makefile+variable, so it overrides on any of the simulator targets.++Note there can be two devices with the same name (`xcrun simctl list devices+available | grep "iPhone 17 Pro"`), and `-destination name=…` picks between them+without saying which — so "it worked in my manual run" proves less than it looks.+ ## Misc - `make test-only TEST=AsterismTests/SomeSuite` runs one suite; `TEST` also
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex d65a8eb..080f27d 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -292,7 +292,7 @@ Search: work titles. ### 5.3 Settings (gear) -Sites list: taught sites with mode and display name; tap to re-teach or flip mode. Full-library backup export (§10). Deliberately buried.+Sites list: every stored site — untaught, taught, and articles — with mode and display name (Q10 of `specs/polish-and-export/`); tap to re-teach or flip mode. Full-library backup export (§10). Deliberately buried. --- @@ -306,7 +306,7 @@ Top to bottom: 4. **Notes on this work** — generic free-form notes. 5. **Chapter notes** — entries newest-first (by lastSharedAt), dense rows: rating glyph, chapter title, note preview, date. -Toolbar menu (…): **Export**, **Merge into…**, **Edit details**.+Toolbar menu (…): **Export**, **Merge into…**, **Edit details**, **Delete work** (§9). ### 6.1 Merge semantics @@ -337,7 +337,7 @@ Markdown, via the share sheet as a `.md` file. **An export is a document, not a Entry-centric renderer, two wrappers: -**Entry block** (also the whole of a per-entry export):+**Entry block** — the unit a per-work document repeats, and the whole of a per-entry export apart from the work line below: ```markdown ## [Ch. 340 — Signal Fires](https://royalroad.com/fiction/48112/…/chapter/1022710)@@ -346,12 +346,14 @@ Entry-centric renderer, two wrappers: Three fires reveal is great. Who lit the third? Calling it now: Ilse. ``` -- Rating: `▲` or `▼` on the date line; **unrated shows nothing**.+- Rating: `▲` or `▼` on the date line; **unrated shows nothing**. The date is the entry's firstCapturedAt, matching the order the per-work document is built in. - Note text verbatim; an empty note renders as heading + date line only. - Unattached/article entries use the **cleaned capture title** (junk-strip applied) as the heading. - The link is the entry's rawURL. -**Per-work export**: H1 work title; a site line that links to the work's **human-confirmed workURL** when one exists (§4.5) and shows the site name unlinked otherwise; generic notes; then entry blocks **oldest-first** — reading order for a document that will be re-read or fed to an AI.+**Standalone per-entry export**: the same block with a **work line** between the heading and the date line — the work's display title in emphasis, then its type in parentheses, unlinked: `*The Perfect Run* (novel)`. The type is dropped when it is the untyped default; the whole line is dropped for unattached entries and for titles empty once trimmed. Inside a per-work document the H1 already names the work, so the line never appears there. A bare "Chapter 14" is useless as a shared or archived document; the line adds identification only, not the metadata dump this section forbids (`specs/polish-and-export/`, Decision 4).++**Per-work export**: H1 work title; a site line that links to the work's **human-confirmed workURL** when one exists (§4.5) and shows the site name unlinked otherwise; generic notes; then entry blocks **oldest-first by firstCapturedAt** — reading order for a document that will be re-read or fed to an AI. Not the feed's lastSharedAt (§7): a re-read is recency in the feed, but it must not float a chapter out of place in the document (`specs/polish-and-export/`, Decision 2). Unattached entries export as a bare entry block. No full-library markdown export. @@ -527,7 +529,7 @@ Four things the spec settled that this paragraph did not anticipate, each of whi Deliberately last, and it paid: the reconciler was written against the states M4a and M4b had already made observable rather than against a guess about which kinds occur. -**M5 — Polish & export.**-Markdown exports (per-work, per-entry); search on both tabs; rating pulse; Sites settings screen; deletion prompts and edge behaviours; visual pass (Liquid Glass conventions per the mockups).+**M5 — Polish & export.** *Spec: `specs/polish-and-export/`.*+Markdown exports (per-work, per-entry); search on both tabs; rating pulse; Sites settings screen; deletion prompts and edge behaviours; visual pass (Liquid Glass conventions per the mockups). Also the **articles-mode exit** M2 deferred to here (§4.6): re-teaching an articles site is accepted from the Sites screen and from nowhere else, returning the site to taught mode with the normal retroactive semantics (§4.7) and clearing its junk-suffix rule. There is still no reverse mode toggle, and the gate that stops inbox-driven teaching from converting an articles site by accident holds on every other path. Sequencing note: the whole M4 group precedes M5 because sync issues surface only with time and multiple devices in play — the earlier CloudKit runs against real captures, the more of its edge cases M5's polish period absorbs. The split does not weaken that: M4a is short and offline, and M4b still gets mirroring in front of real data well before M5. The accepted cost is a window during M4b in which duplicates accumulate unreconciled, which M4a guarantees will degrade rather than fail.
diff --git a/docs/asterism-style-guide.md b/docs/asterism-style-guide.mdindex 07d3bbf..dff5cee 100644--- a/docs/asterism-style-guide.md+++ b/docs/asterism-style-guide.md@@ -8,7 +8,7 @@ Target: iOS 26+, SwiftUI, Liquid Glass. Guiding rule: **the notes are the conten ## 1. Design language in one paragraph -Night-sky field behind everything (dark) or warm paper with faint accent auras (light). Floating glass surfaces with bright specular top edges. Serif work titles. Two cool accents — cyan and violet — at identical lightness/chroma, plus amber reserved exclusively for "needs teaching." Stars (✦) as the recurring glyph motif. Glow is an accent, tuned to 1.3× of subtle — never a wash.+Night-sky field behind everything (dark) or warm paper with faint accent auras (light). Floating glass surfaces with bright specular top edges. Serif work titles. Two cool accents — cyan and violet — at identical lightness/chroma, plus amber reserved for "needs the reader's attention." Stars (✦) as the recurring glyph motif. Glow is an accent, tuned to 1.3× of subtle — never a wash. ## 2. Color @@ -20,9 +20,9 @@ All accents are defined in oklch; keep L and C locked and vary only hue so the p |---|---|---| | **Cyan — primary/positive**: active tab, links, ▲ rating, focus/cursor, work names in previews, "✓" | `oklch(0.82 0.12 220)` | `oklch(0.50 0.13 220)` | | **Violet — secondary/negative**: ▼ rating, work *type* tag, secondary star accents | `oklch(0.82 0.12 305)` | `oklch(0.50 0.13 305)` |-| **Amber — teaching only**: unparsed banner, Teach pill, unparsed row border, "?" glyph, chapter chips in teach mode | `oklch(0.82 0.12 85)` (text bumps to `0.85 0.11 85`) | fill `oklch(0.55 0.12 80)`, text on light `oklch(0.42 0.10 78)` |+| **Amber — actionable attention**: unparsed banner, Teach pill, unparsed row border, "?" glyph, chapter chips in teach mode, duplicate banner, Resolve pill, duplicate-resolution sheet, failure and conflict banners | `oklch(0.82 0.12 85)` (text bumps to `0.85 0.11 85`) | fill `oklch(0.55 0.12 80)`, text on light `oklch(0.42 0.10 78)` | -Amber never appears outside teaching/unparsed contexts. Cyan vs violet: cyan is "yes/active/go", violet is "no/meta". Rating glyphs are text-colored ▲/▼, never filled buttons in rows.+Amber marks a state the reader has to clear — teaching/unparsed, duplicate review, edit conflicts — and never appears outside those. The Resolve pill uses the Teach pill's recipe exactly (`specs/polish-and-export/`, Decision 1). There is no error red: the palette has no fourth hue, so a recoverable failure (save, sync) takes an amber banner with a warning glyph, and the saved-state "✓" stays cyan. Cyan vs violet: cyan is "yes/active/go", violet is "no/meta". Rating glyphs are text-colored ▲/▼, never filled buttons in rows. ### Neutrals @@ -117,7 +117,7 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field ## 11. Anti-rules -- No amber outside teaching. No third accent hue.+- No amber outside actionable-attention contexts (teaching/unparsed, duplicate review, conflicts). No third accent hue — and no error red; a failure banner is amber. - No gradient fills on anything except the primary button, site glyphs, and the background layer. - No borders + shadows stacked on flat fields inside sheets. - No serif in body text or controls. No emoji. No opaque nav bars — content scrolls under glass.
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 6af4523..8aeab4e 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -11,7 +11,7 @@ | [CloudKit Mirroring](#cloudkit-mirroring) | 2026-07-26 | Done — **one requirement unmeasured, two budgets breached**. All 27 tasks complete 2026-07-31; two-device runbook passed, mirroring enabled for both configurations. Req 9.1's numbers await an approval-gated device run (Q54). **T-2053's two red budgets are retired**: re-measured after M4c, `capture-projection-duplicateSiteRows` is 76.3–79.1 ms against 100 ms and `diagnosis-refresh` 0.294–0.298 s against 0.4 s, both green | Enables mirroring on separate containers per configuration, app-only. Site rows consolidate additively (never deleted) with rule versions renumbered deterministically; export refuses only three named states and projects everything else; import is a modification-guarded chunked upsert. No archive format change. | | [Configuration Identity](#configuration-identity) | 2026-07-29 | Done | Single-sources the App Group and CloudKit container identifiers: one identity token per configuration, everything derived, divergence fails the build or the lint. Deletes `LibraryEnvironment`. Prerequisite for the CloudKit Mirroring Development flip (T-1982). | | [Duplicate Reconciliation](#duplicate-reconciliation) | 2026-08-01 | Done — all 23 tasks complete 2026-08-03. **One budget breached, recorded and host-only.** Req 10.1's 2 s settling pass measures 7.264–7.365 s after the pre-push review fixes, down from 8.861–9.080 s once Decision 29 chunked the deletion saves — the transaction count turned out to be ~18% of the pass, not the whole of it, so Decision 27 stands with the remaining cost unattributed (T-2093). `reconcile-noop-coherent`, which Decision 28 recorded at 0.286–0.298 s, is back to 1.82–2.00 ms now that Decision 30 gates the full tier; T-2092 is answered and the known issue removed. Req 10.2's two named baselines both improved, and `recentPresentation` is back to its pre-M4c band. Nothing measured on device | Phase 3 of the M4 split: duplicate Entry/Work/rule sets resolve silently where nothing reader-authored is at stake (collapse for distinct UUIDs, convergence for identity groups — rows sharing a UUID are never split), divergent sets reach a reader resolution sheet or Merge, and export projects groups instead of refusing on them. |-| [Polish & Export](#polish--export) | 2026-08-03 | Planned | M5, the final v1 milestone: markdown export, search, the §6 Work-detail shape, the Sites settings screen (including the articles exit), work deletion, and the Constellation visual pass. |+| [Polish & Export](#polish--export) | 2026-08-03 | Done | M5, the final v1 milestone: markdown export, search, the §6 Work-detail shape, the Sites settings screen (including the articles exit), work deletion, and the Constellation visual pass. | ---
diff --git a/specs/polish-and-export/decision_log.md b/specs/polish-and-export/decision_log.mdindex b03e4d5..33cb097 100644--- a/specs/polish-and-export/decision_log.md+++ b/specs/polish-and-export/decision_log.md@@ -35,6 +35,21 @@ | Q29 | 2026-08-04 | `commitWorkDeletion` returns its own `WorkDeletionCommitOutcome` (committed/refreshed/conflict/invalidated) | No existing outcome type holds all four states; mirrors `WorkMergeCommitOutcome` plus the disclosure conflict | | Q30 | 2026-08-04 | Style tokens live in a new `ConstellationKit` library target inside the AsterismCore package, not in AsterismCore itself | The extension can import it; AsterismCore stays SwiftUI-free | | Q31 | 2026-08-04 | Serif typography tokens are text-style-relative (Dynamic Type), not fixed point sizes | Req 11.3; the current fixed-size `serifTitle` does not scale |+| Q32 | 2026-08-04 | The articles→taught re-teach **clears** `site.junkSuffixRule` instead of retaining it inert (design said retained) | The closed tuple table refuses a non-articles Site holding a junk rule, and it gates every library *open* (`V2LibraryValidator` via `validateStore`) — a retained rule leaves a library the app will not open. Verified by probe. Switching back to articles authors a fresh rule anyway |+| Q33 | 2026-08-04 | The same conversion promotes articles-marked unattached Entries from `.none` to `.manual` assignment provenance | `V4LibraryValidator` reads `.none` + `intentionallyUnattached` as legal *only* in articles mode, so the mode flip would invalidate every one of them and roll the conversion back. `.manual` is what `moveEntry`'s leave-unattached branch writes for the same state (Q22), and the mark itself survives as §2.6 requires |+| Q34 | 2026-08-04 | `commitWorkDeletion`'s validator gate compares against the hostname's **prior** diagnosis rather than against nil | Merge's gate compares against nil, which is what made a diagnosed hostname unteachable until Decision 8 of `title-teaching-retroactive-parsing`; a work on a hostname that already carries a tolerated diagnosis must still be deletable |+| Q35 | 2026-08-04 | `entryGroupIDs` on the deletion contract names the **owned** groups only, not the shared ones | It is what the dialog counts and what the commit takes; a shared group is repointed (Q25), so its arrival or departure is not a change to what the reader confirmed |+| Q36 | 2026-08-04 | Teach-chip colours follow the style guide's §7 mapping (chapter = amber, work = cyan) when tasks 28–30 adopt `constellationChip`; the shipped inverse mapping in `TitleChipView`/`ComposedURLDetailsEditor` is superseded | The guide is internally coherent (§2: cyan = work names, amber = chapter chips in teach mode) and is the approved design; the Core-phase token migration deliberately did not change visual semantics, so the flip happens at adoption, not before |+| Q37 | 2026-08-04 | Error/save-failure banners use **amber**, not system red; the saved-state checkmark uses cyan, not green | The palette has no error colour and §11 forbids a fourth accent hue; the warning icon carries the alarm and the failures are recoverable. Applied in the share extension this phase; the app's remaining `.red` surfaces align during adoption tasks 28–30 |+| Q38 | 2026-08-04 | `commitWorkDeletion` records the hostname's post-commit diagnosis, mirroring `commitComposedTeaching` | Review finding: without it, a deletion that removes the offending record leaves the hostname quarantined in memory until the next open. Regression-tested (test fails with the call removed). Test support gained `m5RepublishDiagnoses()` because the M5 seeder writes after the open, so the in-memory map was empty and Q34's positive half was untestable |+| Q39 | 2026-08-04 | Recent's search-empty state is withheld while the Elsewhere section is on screen | Q13 keeps Elsewhere lines visible under a query; replacing the list with `ContentUnavailableView.search` would hide lines the duplicate banner's count references, breaking the "row or a line, never neither" invariant. Design named the branch's condition (query non-empty, zero matches) but not its interaction with Elsewhere |+| Q40 | 2026-08-04 | `MarkdownExportModel` scavenges its staging directory by file extension, not by the filename prefix `BackupV4Exporter` keys on | Export filenames come from the reader's own titles (Q11), so there is no prefix to key on; the directory is exclusively markdown exports', which makes it the honest identifier |+| Q41 | 2026-08-04 | `SiteSnapshot` gains a `#if DEBUG` public `fixture(...)` factory (task 5 said the init stays internal) | The Sites view models live in the app target, so their tests cannot state a row without one. Guarded like the existing performance fixtures, so no release build can construct a snapshot and "the repository is the only producer" still holds where it matters |+| Q42 | 2026-08-04 | `ConstellationSectionHeader` renders as one concatenated `Text` (inline ✦ image + label), not an `HStack` of an `Image` and a `Text` | A header carrying an accessibility identifier has to stay a *static-text* element — `WorksAndAssignmentUITests` queries `staticTexts["unattached-section-header"]`, and a container would report as an "other". The ✦ prefix, tracking, and dim colour are unchanged |+| Q43 | 2026-08-04 | The sky attaches to each screen's own root, not to the enclosing `NavigationStack` (design said `.background()` on the stack); `WorkDetailView` and `LibraryDiagnosticsView` take a `showsSky` flag | Measured in the simulator: a background applied outside the stack renders behind the stack's own opaque backing and never reaches the screen (sampled pure black; sampled the auras once moved inside). The two flagged screens render both inside a tab stack and inside a sheet, and §4 gives sheets their own glass layer instead |+| Q44 | 2026-08-04 | Requirement 11.1's "opaque fills replace materials" is pinned by the branch in `ConstellationCard`/`ConstellationSheetSurface` plus a reachability journey, not by a pixel assertion in the UI tests | Reduce Transparency cannot be turned on from a simulator UI test: neither the `-UIAccessibilityReduceTransparency YES` launch argument the suite has always passed nor writing `com.apple.Accessibility` defaults into the booted device changes what the environment reports — both were measured against the rendered pixels and the row kept its material |+| Q45 | 2026-08-04 | `AccessibilityJourneyUITests` dirties the work title before asserting `work-detail-save-button` | Task 21 moved Save to a toolbar confirmation shown only while a draft differs (Req 5.6). The identifier is unchanged — the shipped behaviour is what moved, and a journey that asserts the control unconditionally is asserting the old screen |+| Q46 | 2026-08-04 | The duplicate-resolution sheet's "Which copy do you want to keep?" keeps the section-header font in amber, sentence-cased and without the ✦ prefix — the one documented exception to 8.4's header treatment | It is the sheet's question, not a label over a list: uppercasing and letter-spacing a question reads as shouting, and dimming it drops the actionable-attention signal Decision 1 licenses on duplicate-review surfaces. Recorded rather than left undocumented under 8.7's "no new styles" rule | ## Decision 1: Amber extends to duplicate-review surfaces as the "actionable attention" accent
diff --git a/specs/polish-and-export/implementation.md b/specs/polish-and-export/implementation.mdnew file mode 100644index 0000000..e45d707--- /dev/null+++ b/specs/polish-and-export/implementation.md@@ -0,0 +1,187 @@+# Implementation Explanation: Polish & Export (M5)++Branch `feature/polish-and-export`, commits `db1c42c..HEAD` (26 commits). Written+as part of the pre-push review; the three levels serve different readers, and the+Completeness Assessment at the end records what shipped against the spec.++---++## Beginner Level++### What Changed++This milestone finishes the app's version 1. Five things are new for a reader:++1. **Search.** The Recent tab and the Works tab each have a search field. Recent+ searches your notes, chapter titles, and work titles; Works searches work+ titles. Accents and capital letters don't matter ("cafe" finds "Café").+2. **Export.** An entry — or a whole work with all its entries — can be exported+ as a markdown file and shared anywhere (Files, Notes, a message). The file is+ named after the chapter or work.+3. **A rebuilt work screen.** A work's page now opens with a button that jumps to+ the last chapter you noted, shows how your rating changed over time, and holds+ all its actions (Export, Merge, Edit details, Delete) in one menu.+4. **Deleting a work.** For the first time you can delete a work. The app tells+ you exactly how many notes that takes with it, and offers to keep those notes+ as "unattached" instead of deleting them.+5. **A Sites screen and a new look.** Settings lists every website you've+ captured from and what the app has been taught about each. And the whole app+ wears a new "Constellation" design — a night-sky backdrop, card-style rows,+ site glyphs, serif titles — that finally works in light mode too.++### Why It Matters++Your reading notes stop being locked in the app: search finds them, export gets+them out. Deleting a work closes the last gap where a mistake was permanent. The+design pass makes the app consistent — one colour means one thing everywhere:+amber means "this needs you", cyan means "yes/confirmed".++### Key Concepts++- **Markdown**: a plain-text format using symbols like `#` for headings. It's+ the common language of note apps, so an exported file works everywhere.+- **Tolerated state**: the app's phrase for a library that has something odd in+ it (say, sync duplicated a record) but still works. A rule in this milestone:+ export never refuses — an imperfect library still exports.+- **Site glyph**: the coloured emblem shown for a website, derived from the+ site's name — so a site keeps the same colour everywhere without storing one.++---++## Intermediate Level++### Changes Overview++Four phases, ~9,500 lines across 90+ files:++- **Core (AsterismCore package)**: `MarkdownExport` (pure renderer + filename+ API), `LibraryRepository` extensions for export inputs, sites list, work+ detail, and work deletion (projection/commit pair), and a flagged+ articles→taught conversion through composed teaching.+- **ConstellationKit (new library target in the same package)**: adaptive colour+ tokens, typography, layout constants, surfaces/recipes/button styles,+ `SiteGlyph` with FNV-1a hue derivation, and the sky background. The old+ hard-coded-dark `AsterismStyle.swift` is deleted.+- **App target**: `RecentSearchFilter`/`WorksSearchFilter` (pure structs),+ `MarkdownExportModel` (staging + share sheet), rebuilt `WorkDetailView` to the+ design's §6 shape with the deletion flow, `SitesListModel`/`SiteDetailModel` ++ `SitesView`, and Constellation adoption across every screen.+- **Share extension**: restyled to the same tokens, now light/dark adaptive.++### Implementation Approach++- **House patterns reused deliberately.** Work deletion is a projection/commit+ contract pair like merge: project → confirm (with counts) → disclose differing+ duplicate copies → commit with a validate-before-save pass and rollback.+ Export follows the backup exporter's three-layer split (repository input reads+ → pure renderer → staging model); `ExportStaging` now holds the shared+ file-writing/scavenging code for both.+- **Dialogs carry their data.** Every confirmation dialog passes its contract on+ the presented value, because SwiftUI runs the dialog's dismissal handler+ (which clears model state) before the button action. This bug class was hit+ twice on this branch (work deletion, articles switch), fixed the same way both+ times, and audited across all four of the app's dialogs.+- **One writer for manual assignment.** `applyManualAssignment` centralises the+ six-field "leave unattached" write, closing a hole where detach (and+ `moveEntry` before it) left URL-rule references on a `.manual` row — a state+ the validator refuses.+- **Search is in-memory filtering** in pure app-target structs (Q19): both tabs+ are already pure functions of one read, and diacritic-insensitive matching+ can't be a store predicate anyway. Filters run after banner filters (Q6) and+ are hoisted to one evaluation per body pass.++### Trade-offs++- **Native-first visual pass (Q15/Q16)**: system `TabView`/`.searchable`/glass+ instead of exact mockup geometry; hand-rolled only what has no system+ equivalent. Costs the mockups' tab-bar glow; buys Reduce Transparency,+ Dynamic Type, and keyboard behaviour for free.+- **Tokens over per-screen colours**: ConstellationKit is a separate library+ target so the extension can import it while AsterismCore stays SwiftUI-free+ (Q30).+- **The articles conversion deviates from the design** (Q32/Q33): the design+ said "retain the junk rule inert", but the tuple validator that gates every+ library open refuses that state, so the conversion clears the rule and+ promotes `.none` provenance to `.manual`. Verified by probe; recorded.++---++## Expert Level++### Technical Deep Dive++- **Renderer purity is load-bearing.** `MarkdownExport` takes pre-formatted+ strings only (dates formatted in the input layer with an explicit `Locale`+ parameter; `en_AU` pinned in tests, `.current` overloads for production).+ Escaping owns the full metacharacter set including `<`, `>` and backslash;+ headings/work/date lines are separate paragraphs (Q18) because adjacent lines+ merge in CommonMark and trailing-space line breaks don't survive editors.+- **Export's tolerated-state guarantees**: split entry groups render once from+ the carrier row via `fetchNormalisedEntryGroup`; the work-line fetch tolerates+ only `recordNotFound` (dangling `workID` drops the line; real store errors+ rethrow). Block ordering reuses `GroupOrdering.sortedSurvivorCandidates` so+ the tie-break has one spelling.+- **Work deletion's honesty machinery**: the contract's `entryGroupIDs` names+ owned groups only (Q35) — shared duplicate-set groups are repointed to the+ surviving member via the reconciler's mechanism (Q25), so drift in the owned+ set triggers `.refreshed` re-presentation (Q28) while a newly-arrived shared+ group is handled non-destructively without re-confirmation. The validator gate+ compares against the hostname's *prior* diagnosis rather than nil (Q34),+ adopting the lesson from title-teaching's Decision 8; `commitWorkDeletion`+ records the post-commit diagnosis so removing the offending record clears the+ in-memory quarantine without a relaunch.+- **The dismissal-order bug class**: SwiftUI writes `isPresented = false`+ (running the binding's cancel path) before the button action's `Task` body.+ Any dialog whose confirm path re-reads model state its cancel path clears is+ a silent no-op — invisible to model tests that call methods in reader order.+ The regression tests now call the two in SwiftUI's order.++### Architecture Impact++- `LibraryProviding` grew six requirements (sites, workDetail, export inputs,+ deletion pair). `MockLibraryProvider` mirrors them; UI seeding gained+ `m5RepublishDiagnoses()` because M5's fixtures write after the open, so+ quarantine-map-dependent paths were previously untestable.+- ConstellationKit is now the single owner of colour semantics. The amber+ accent's meaning widened from "needs teaching" to "actionable attention"+ (Decision 1) — reflected in the style guide by task 34. Error red is gone;+ recoverable failures are amber, confirmations cyan (Q37).+- The chip mapping flip (Q36) landed with adoption: chapter = amber,+ work = cyan, per style guide §7, including the previews beneath the chips.++### Potential Issues++- **`MarkdownExportModel` scavenges in `init`** (synchronous directory scan) and+ the model is rebuilt per body re-evaluation of a presented detail screen. A+ test pins the init-time behaviour, so moving it to `startExport()` needs a+ deliberate test change — left as a recorded open item from the pre-push+ review.+- **Reduce Transparency is untestable from simulator UI tests** (Q44) — the+ requirement is pinned by code branch + reachability journey, not pixels.+- **Recipe fidelity limits**: single adaptive Colors can't express the style+ guide's per-appearance alphas (banner fills, button glow offsets); Q8.5's+ arbiter is mockup comparison at adoption. Recorded, not hidden.+- **`test-performance-m4` remains knowingly red** (pre-existing); nothing in M5+ touched the measured paths, and Q7 deliberately declined budgets for export+ and search.++---++## Completeness Assessment++**Fully implemented**: all 34 tasks across the four phases; requirements 1–11 as+amended by the decision log (Q32–Q46). Export (per-entry and per-work, torn+records included), search on both tabs, the §6 work detail with deletion, the+Sites screen with the articles exit, the Constellation visual pass with light+mode, and the UI-test journeys (13 new) covering the functional surfaces.++**Partially implemented / recorded deviations**: the articles conversion clears+the junk rule instead of retaining it inert (Q32, validator-forced); the native+tab bar drops the dark-mode glow (Q16); banner/pill alphas approximate the+guide's per-appearance values within one-adaptive-Color limits (reviewed,+deferred to mockup comparison).++**Open items**: moving the export scavenge out of `init` (needs a one-line test+retitle — flagged for the author); merge's validator gate still compares against+nil (pre-existing, out of M5 scope — works on a diagnosed hostname remain+un-mergeable; noted during review).
diff --git a/specs/polish-and-export/tasks.md b/specs/polish-and-export/tasks.mdindex 886e1f6..8e92e35 100644--- a/specs/polish-and-export/tasks.md+++ b/specs/polish-and-export/tasks.md@@ -8,7 +8,7 @@ references: ## Core -- [ ] 1. Write failing tests for the MarkdownExport renderer and filename API <!-- id:bm3w79n -->+- [x] 1. Write failing tests for the MarkdownExport renderer and filename API <!-- id:bm3w79n --> - AsterismCoreTests. Golden strings per AC: rated/unrated/empty note; work line present, omitted for .other/unattached/whitespace-title/embedded; per-work assembly incl. - empty work and workURL-linked vs unlinked site line. - Adversarial escaping table: [ ] * _ ` # < > backslash in titles, ( ) and spaces in URLs, surrounding whitespace, internal newlines, emoji, RTL — assert literal-text rendering and that heading/work/date lines stay distinct paragraphs.@@ -17,20 +17,20 @@ references: - Stream: 1 - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.9](requirements.md#1.9), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.6](requirements.md#2.6) -- [ ] 2. Implement MarkdownExport renderer, input types, and filename API <!-- id:bm3w79o -->+- [x] 2. Implement MarkdownExport renderer, input types, and filename API <!-- id:bm3w79o --> - New file in AsterismCore (pure, no I/O). EntryExportInput carries headingText (cleaned, raw text), rawURLString, workTitle/workTypeLabel (nil label for .other), dateText, rating, note; WorkExportInput carries titleText, siteName, workURLString?, genericNotes, blocks. - Renderer owns trim/newline-collapse/escape for all title-bearing text and assembles the work line (Q18: blank-line paragraph separation). - Blocked-by: bm3w79n (Write failing tests for the MarkdownExport renderer and filename API) - Stream: 1 - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.9](requirements.md#1.9), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.6](requirements.md#2.6) -- [ ] 3. Write failing tests for the export input reads <!-- id:bm3w79p -->+- [x] 3. Write failing tests for the export input reads <!-- id:bm3w79p --> - Against tolerated-state fixtures: split entry group renders one block (carrier content, torn included — export never refuses); firstCapturedAt ordering survives a re-share; cleaned heading for articles (junk-strip) and taught-trim sites; work line fields resolved from the work group's carrier; site displayName with hostname fallback; locale parameter reaches dateText; empty-work input shape. - Blocked-by: bm3w79o (Implement MarkdownExport renderer, input types, and filename API) - Stream: 1 - Requirements: [1.2](requirements.md#1.2), [1.8](requirements.md#1.8), [2.1](requirements.md#2.1), [2.3](requirements.md#2.3), [2.5](requirements.md#2.5) -- [ ] 4. Implement entryExportInput and workExportInput on LibraryRepository <!-- id:bm3w79q -->+- [x] 4. Implement entryExportInput and workExportInput on LibraryRepository <!-- id:bm3w79q --> - LibraryRepository, .shared lock, one read each. - Groups via fetchEntryGroup/fetchWorkGroup with non-defaulted canonicalWorkIDs (+Groups.swift:120,146); cleaned titles via presentationTitle(for:siteMode:site:) (+EntryDetail.swift:222). - Builder signature takes locale: Locale = .current. Add both to LibraryProviding.@@ -38,37 +38,37 @@ references: - Stream: 1 - Requirements: [1.2](requirements.md#1.2), [1.8](requirements.md#1.8), [2.1](requirements.md#2.1), [2.3](requirements.md#2.3), [2.5](requirements.md#2.5) -- [ ] 5. Write failing tests for the sites() read <!-- id:bm3w79r -->+- [x] 5. Write failing tests for the sites() read <!-- id:bm3w79r --> - Duplicate Site rows resolve to one row per hostname via SiteResolutionOrder.winnersByHostname (IdentityResolution.swift:33); sorted by display name; all three modes represented; empty store returns []. - SiteSnapshot init stays internal — repository is the only producer. - Stream: 1 - Requirements: [6.1](requirements.md#6.1) -- [ ] 6. Implement sites() producing SiteSnapshot winners <!-- id:bm3w79s -->+- [x] 6. Implement sites() producing SiteSnapshot winners <!-- id:bm3w79s --> - New LibraryProviding method; .shared lock. - First production use of SiteSnapshot (Snapshots.swift:113). - Blocked-by: bm3w79r (Write failing tests for the sites() read) - Stream: 1 - Requirements: [6.1](requirements.md#6.1) -- [ ] 7. Write failing tests for the workDetail(id:) read <!-- id:bm3w79t -->+- [x] 7. Write failing tests for the workDetail(id:) read <!-- id:bm3w79t --> - Pulse counts over logical records (a split entry group counts once); lastNotedURLString = rawURL of newest lastSharedAt entry, nil for empty work; chapter rows newest-first with displayTitle = chapterTitle ?? cleaned captureTitle. - Stream: 1 - Requirements: [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4) -- [ ] 8. Implement workDetail(id:) with RatingPulse and WorkChapterRow <!-- id:bm3w79u -->+- [x] 8. Implement workDetail(id:) with RatingPulse and WorkChapterRow <!-- id:bm3w79u --> - Types per design Data Models: RatingPulse, WorkChapterRow, WorkDetailPresentation. - Reuses the task-4 group/cleaned-title plumbing; add to LibraryProviding. - Blocked-by: bm3w79t (Write failing tests for the workDetail(id:) read) - Stream: 1 - Requirements: [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4) -- [ ] 9. Write failing tests for work deletion project/commit <!-- id:bm3w79v -->+- [x] 9. Write failing tests for work deletion project/commit <!-- id:bm3w79v --> - Cover: both dispositions; torn work group → .conflict(.torn); disclosure gate on BOTH dispositions (disclosedVariants nil → .torn conflict; stale set → .disclosureStale, first mismatched group); shared-group repoint leaves group whole, untorn, attached to survivor (Q25); entryGroupIDs drift → .refreshed (Q28); gone target → .committed; detach applies moveEntry's six-field leave-unattached block (LibraryRepository.swift:1320–1330) and a later re-parse never reattaches; hostname-scoped validator rollback → .invalidated; duplicateWorkload re-derivation drops the deleted member (7.6). - Stream: 1 - Requirements: [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5), [7.6](requirements.md#7.6) -- [ ] 10. Implement projectWorkDeletion, commitWorkDeletion, and resolveWorkDeletionTarget <!-- id:bm3w79w -->+- [x] 10. Implement projectWorkDeletion, commitWorkDeletion, and resolveWorkDeletionTarget <!-- id:bm3w79w --> - resolveWorkDeletionTarget(id:basis:context:) -> ResolvedWriteTarget<WorkGroup?> — work-side analogue of resolveEntryDeletionTarget (+Redirect.swift:87); resolveWorkWriteTarget throws recordNotFound and cannot substitute. - Commit steps 1–7 per design; repoint via the DuplicateReconciler.repointEntries mechanism (DuplicateReconciler.swift:588); scoped validator V4LibraryValidator.validate(hostnames:context:) (V4LibraryValidator.swift:100); single save. - New WorkDeletionDisposition/WorkDeletionContract/WorkDeletionCommitOutcome types.@@ -76,13 +76,13 @@ references: - Stream: 1 - Requirements: [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5), [7.6](requirements.md#7.6) -- [ ] 11. Write failing tests for the articles-site re-teach flag <!-- id:bm3w79x -->+- [x] 11. Write failing tests for the articles-site re-teach flag <!-- id:bm3w79x --> - Default flag: projection/commit still refuse articles sites (existing gates at +Contracts.swift:35–40, +ComposedTeaching.swift:102–103). - Flag set: projection accepts, commit writes site.mode = .taught (+ComposedTeaching.swift:164); junkSuffixRule retained on the row but inert; intentionally-unattached entries stay unattached through conversion (§2.6). - Stream: 1 - Requirements: [6.3](requirements.md#6.3) -- [ ] 12. Implement permitsArticlesConversion through composed-teaching projection and commit <!-- id:bm3w79y -->+- [x] 12. Implement permitsArticlesConversion through composed-teaching projection and commit <!-- id:bm3w79y --> - permitsArticlesConversion: Bool = false on ComposedTeachingRequest; thread through projectComposedTeaching and commitComposedTeaching. - Gate lifts only when the flag is set — every existing call site keeps the default. - Blocked-by: bm3w79x (Write failing tests for the articles-site re-teach flag)@@ -91,20 +91,20 @@ references: ## App functional -- [ ] 13. Write failing tests for RecentSearchFilter and WorksSearchFilter <!-- id:bm3w79z -->+- [x] 13. Write failing tests for RecentSearchFilter and WorksSearchFilter <!-- id:bm3w79z --> - App-target unit tests (make test-quick). Case + diacritic folding; Recent fields note/chapterTitle/workDisplayTitle with cleaned-captureTitle fallback (3.3); runs after banner filtering (composition, Q6); day groups preserved, emptied groups dropped; Works matches displayTitle only, existing ordering (incl. - empty-work sinking) preserved; unattached entries never match. - Stream: 1 - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3) -- [ ] 14. Implement the search filter structs <!-- id:bm3w7a0 -->+- [x] 14. Implement the search filter structs <!-- id:bm3w7a0 --> - Two pure structs in ViewModels/ operating on RecentPresentationGroup rows and WorksSnapshot. - String.range(of:options:[.caseInsensitive, .diacriticInsensitive]). Elsewhere lines are NOT filtered (Q13). - Blocked-by: bm3w79z (Write failing tests for RecentSearchFilter and WorksSearchFilter) - Stream: 1 - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.5](requirements.md#3.5), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2) -- [ ] 15. Wire .searchable into RecentView and WorksView <!-- id:bm3w7a1 -->+- [x] 15. Wire .searchable into RecentView and WorksView <!-- id:bm3w7a1 --> - .searchable on the always-present outer container (RecentView's List is swapped for the empty branch at RecentView.swift:91–126). - New search-empty branch (query non-empty, zero matches) renders ContentUnavailableView.search — the existing empty branch keys on presentation.groups.isEmpty and never fires for a no-match query. - WorksView hides the Unattached section while the query is non-empty.@@ -113,36 +113,36 @@ references: - Stream: 1 - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3) -- [ ] 16. Write failing tests for MarkdownExportModel <!-- id:bm3w7a2 -->+- [x] 16. Write failing tests for MarkdownExportModel <!-- id:bm3w7a2 --> - Mirror SettingsBackupModelTests: .idle/.exporting/.sharing/.failed transitions, staging write under Library/Caches/MarkdownExports/, cleanup on share dismiss, stale-file scavenging, privacy-safe failure messages (no paths/titles). - Stream: 1 - Requirements: [1.5](requirements.md#1.5), [2.4](requirements.md#2.4) -- [ ] 17. Implement MarkdownExportModel and the shared ShareSheet <!-- id:bm3w7a3 -->+- [x] 17. Implement MarkdownExportModel and the shared ShareSheet <!-- id:bm3w7a3 --> - MarkdownExportModel in ViewModels/ (sibling of SettingsBackupModel); move ShareSheet out of SettingsView.swift:298 (private there) into Views/ShareSheet.swift unchanged; AppLibraryModel factories beside settingsBackupModel() (:924). - Blocked-by: bm3w7a2 (Write failing tests for MarkdownExportModel), bm3w79q (Implement entryExportInput and workExportInput on LibraryRepository) - Stream: 1 - Requirements: [1.5](requirements.md#1.5), [2.4](requirements.md#2.4) -- [ ] 18. Wire the Export action into Entry detail <!-- id:bm3w7a4 -->+- [x] 18. Wire the Export action into Entry detail <!-- id:bm3w7a4 --> - Export row in EntryDetailView's actions section (EntryDetailView.swift:120–146), identifier entry-detail-export-button; presents the shared ShareSheet via MarkdownExportModel. - Blocked-by: bm3w7a3 (Implement MarkdownExportModel and the shared ShareSheet) - Stream: 1 - Requirements: [1.1](requirements.md#1.1) -- [ ] 19. Write failing tests for WorkDetailModel changes <!-- id:bm3w7a5 -->+- [x] 19. Write failing tests for WorkDetailModel changes <!-- id:bm3w7a5 --> - Adopt workDetail(id:) read; expose pulse + lastNotedURL + chapter rows; delete flow (confirmationDialog state → optional disclosure → commitWorkDeletion with disclosedVariants passed as parameter, not re-read from state — EntryDetailModel.swift:291–298 records why); .refreshed/.conflict/.invalidated handling; Edit-details sheet split keeps save() path intact. - Blocked-by: bm3w79u (Implement workDetail(id:) with RatingPulse and WorkChapterRow), bm3w79w (Implement projectWorkDeletion, commitWorkDeletion, and resolveWorkDeletionTarget) - Stream: 1 - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [7.1](requirements.md#7.1) -- [ ] 20. Implement the WorkDetailModel changes <!-- id:bm3w7a6 -->+- [x] 20. Implement the WorkDetailModel changes <!-- id:bm3w7a6 --> - WorkDetailModel: swap work(id:) for workDetail(id:), delete()/confirmDelete mirroring EntryDetailModel.swift:283–341, dismiss + onMutation on .committed. - Blocked-by: bm3w7a5 (Write failing tests for WorkDetailModel changes) - Stream: 1 - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [7.1](requirements.md#7.1) -- [ ] 21. Restructure WorkDetailView to the §6 layout <!-- id:bm3w7a7 -->+- [x] 21. Restructure WorkDetailView to the §6 layout <!-- id:bm3w7a7 --> - §6 order: header (glyph, editable title, site + URL-ID, tags) → rating pulse → Open last noted chapter → generic notes → chapter rows (WorkChapterRow data, not UnattachedEntryRow). - Toolbar Menu work-detail-menu: Export / Merge into… / Edit details / Delete work; Save moves to a toolbar confirmation item shown while drafts are dirty; Edit-details sheet hosts Work URL + URL-identity sections; confirmationDialog (work-detail-delete-*) + torn disclosure alert. - Merge reachable only from the menu (5.5).@@ -150,19 +150,19 @@ references: - Stream: 1 - Requirements: [5.1](requirements.md#5.1), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2) -- [ ] 22. Write failing tests for SitesListModel and SiteDetailModel <!-- id:bm3w7a8 -->+- [x] 22. Write failing tests for SitesListModel and SiteDetailModel <!-- id:bm3w7a8 --> - SitesListModel rows (displayName, hostname, mode label); SiteDetailModel: articles switch via projectArticles/commitArticles with confirmation counts, re-teach disabled with explanatory line when the hostname has no entries (composedTeachingModel(forHostname:) returns nil — AppLibraryModel.swift:468), articles-site re-teach passes permitsArticlesConversion. - Blocked-by: bm3w79s (Implement sites() producing SiteSnapshot winners), bm3w79y (Implement permitsArticlesConversion through composed-teaching projection and commit) - Stream: 1 - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4) -- [ ] 23. Implement the Sites view models <!-- id:bm3w7a9 -->+- [x] 23. Implement the Sites view models <!-- id:bm3w7a9 --> - Models in ViewModels/, wording in models per house convention; factories on AppLibraryModel. - Blocked-by: bm3w7a8 (Write failing tests for SitesListModel and SiteDetailModel) - Stream: 1 - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4) -- [ ] 24. Wire the Sites screens into Settings <!-- id:bm3w7aa -->+- [x] 24. Wire the Sites screens into Settings <!-- id:bm3w7aa --> - Sites Section in SettingsView's List after Data; NavigationLink pushes (Settings sub-screens are pushes — SettingsView.swift:192 precedent); re-teach rides pendingReteachHostname (ContentView.swift:293–302, :350) with the articles-conversion flag on that route; row identifiers settings-sites-*, site-row-<hostname>. - Blocked-by: bm3w7a9 (Implement the Sites view models) - Stream: 1@@ -170,19 +170,19 @@ references: ## Visual pass -- [ ] 25. Create the ConstellationKit target and migrate all token call sites <!-- id:bm3w7ab -->+- [x] 25. Create the ConstellationKit target and migrate all token call sites <!-- id:bm3w7ab --> - New library target in Packages/AsterismCore/Package.swift; adaptive Colors via UIKit dynamic provider behind #if canImport(UIKit) (dark values as fallback so host make test-core compiles); text-style-relative serif (Q31) replacing fixed sizes; delete Asterism/Asterism/Style/AsterismStyle.swift and the *Dark/*Light accessors so the compiler enumerates every call site (9 files hard-code *Dark today); link product to app and extension targets in project.pbxproj. - Config/wiring task — verified by build + existing suites staying green. - Stream: 2 - Requirements: [8.2](requirements.md#8.2), [11.3](requirements.md#11.3), [11.4](requirements.md#11.4) -- [ ] 26. Write failing tests for SiteGlyph color derivation <!-- id:bm3w7ac -->+- [x] 26. Write failing tests for SiteGlyph color derivation <!-- id:bm3w7ac --> - Pure function test: FNV-1a hostname → hue is deterministic across process runs, stable for known hostnames (golden values), and distinct for a small sample set. No stored color field (Q9). - Blocked-by: bm3w7ab (Create the ConstellationKit target and migrate all token call sites) - Stream: 2 - Requirements: [9.2](requirements.md#9.2) -- [ ] 27. Implement the Constellation foundation components <!-- id:bm3w7ad -->+- [x] 27. Implement the Constellation foundation components <!-- id:bm3w7ad --> - ConstellationBackground (gradient + 8 seeded star dots dark-only + two auras); specularTopEdge(); ConstellationSectionHeader; ConstellationPrimaryButtonStyle (gradient capsule + glow); SiteGlyph (?/✎ flat-dim variants); recipes for rating toggles (48×42, active glow), banner capsule, Teach/Resolve pills (both amber, Decision 1), count pills, tags, dashed unattached group, teach chips, provenance disclosure. - Reduce Transparency: opaqueCard* tokens REPLACE material fills (11.1). - Radii per style guide §6; minHitTarget frames (11.2).@@ -190,33 +190,33 @@ references: - Stream: 2 - Requirements: [8.1](requirements.md#8.1), [8.4](requirements.md#8.4), [8.5](requirements.md#8.5), [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6), [11.1](requirements.md#11.1), [11.2](requirements.md#11.2) -- [ ] 28. Adopt Constellation on Recent and Works <!-- id:bm3w7ae -->+- [x] 28. Adopt Constellation on Recent and Works <!-- id:bm3w7ae --> - Sky behind both tab stacks (.scrollContentBackground(.hidden)); rows → material fill + specular edge + SiteGlyph replacing hostname text; serif large titles via global proxy largeTitleTextAttributes ONLY with UIFontMetrics scaling (Q27/Q31); banners/pills/count pills per recipes; amber only in actionable-attention contexts (8.6); lineLimit(1) truncation (11.3). Identifiers unchanged. - Blocked-by: bm3w7ad (Implement the Constellation foundation components), bm3w7a1 (Wire .searchable into RecentView and WorksView) - Stream: 2 - Requirements: [8.1](requirements.md#8.1), [8.3](requirements.md#8.3), [8.6](requirements.md#8.6), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3), [9.4](requirements.md#9.4), [11.3](requirements.md#11.3) -- [ ] 29. Adopt Constellation on Work detail and Entry detail <!-- id:bm3w7af -->+- [x] 29. Adopt Constellation on Work detail and Entry detail <!-- id:bm3w7af --> - Work detail: pulse as three cards (▲ cyan / ▼ violet / neutral), Open-last-noted as the screen's single primary button (9.1). - Entry detail: rating toggle capsules, provenance disclosure recipe (SF Mono, quieter fill). Identifiers unchanged. - Blocked-by: bm3w7ad (Implement the Constellation foundation components), bm3w7a7 (Restructure WorkDetailView to the §6 layout) - Stream: 2 - Requirements: [5.1](requirements.md#5.1), [9.1](requirements.md#9.1), [9.4](requirements.md#9.4) -- [ ] 30. Adopt Constellation on sheets, Settings, and maintenance surfaces <!-- id:bm3w7ag -->+- [x] 30. Adopt Constellation on sheets, Settings, and maintenance surfaces <!-- id:bm3w7ag --> - Sheet-presented surfaces get the §4 sheet treatment, not the sky (design: sky is tab-stack-only). - Resolution sheet + duplicate banner amber per Decision 1; Settings, Sites, diagnostics, import, merge, move-to per 8.7 — token/typography/surface recipes over existing layout, no new component styles. - Blocked-by: bm3w7ad (Implement the Constellation foundation components), bm3w7aa (Wire the Sites screens into Settings) - Stream: 2 - Requirements: [8.6](requirements.md#8.6), [8.7](requirements.md#8.7), [9.4](requirements.md#9.4) -- [ ] 31. Restyle the share-extension capture sheet <!-- id:bm3w7ah -->+- [x] 31. Restyle the share-extension capture sheet <!-- id:bm3w7ah --> - ShareViewController.swift:19: opaque .systemBackground → clear, SwiftUI root supplies sheet glass; CaptureView/ReShareCaptureView swap hard-coded .blue/.purple/.orange/literal 44s for ConstellationKit tokens; serif metadata strip, styled toggles, gradient Save/Update; NO sky layer (10.3); four capture states unchanged (10.2). - Blocked-by: bm3w7ad (Implement the Constellation foundation components) - Stream: 2 - Requirements: [10.1](requirements.md#10.1), [10.2](requirements.md#10.2), [10.3](requirements.md#10.3) -- [ ] 32. Extend UI tests for the new functional surfaces <!-- id:bm3w7ai -->+- [x] 32. Extend UI tests for the new functional surfaces <!-- id:bm3w7ai --> - Simulator only. Search: seeded fixture, query narrows rows, search-empty identifiers, composition with duplicate filter (seeded-tolerated shape). - Work detail: menu items present, export share sheet appears, delete dialog both dispositions (detach → entry lands in Unattached Notes). - Sites: Settings → list → detail → re-teach routes to composed sheet after Settings dismiss; articles switch confirmation.@@ -225,7 +225,7 @@ references: - Stream: 1 - Requirements: [3.4](requirements.md#3.4), [4.3](requirements.md#4.3), [5.5](requirements.md#5.5), [6.2](requirements.md#6.2), [7.1](requirements.md#7.1), [7.4](requirements.md#7.4) -- [ ] 33. Extend the appearance UI tests and reconcile identifiers <!-- id:bm3w7aj -->+- [x] 33. Extend the appearance UI tests and reconcile identifiers <!-- id:bm3w7aj --> - Extend AccessibilityJourneyUITests: light/dark/Reduce-Transparency/largest-Dynamic-Type journeys still pass with unchanged identifiers; add Works-tab appearance journey; verify RT opaque fills replace materials. - Extension appearance covered by extension unit/snapshot checks only if cheap — extension UI tests are deliberately out of simulator scope (docs/agent-notes/composed-teaching-ui.md). - Blocked-by: bm3w7ae (Adopt Constellation on Recent and Works), bm3w7af (Adopt Constellation on Work detail and Entry detail), bm3w7ag (Adopt Constellation on sheets, Settings, and maintenance surfaces), bm3w7ah (Restyle the share-extension capture sheet)@@ -234,7 +234,7 @@ references: ## Documentation -- [ ] 34. Amend docs/asterism-design.md and the style guide for Decisions 1 and 4 <!-- id:bm3w7ak -->+- [x] 34. Amend docs/asterism-design.md and the style guide for Decisions 1 and 4 <!-- id:bm3w7ak --> - Decision 4: §8 gains the standalone work-line rule and drops "also the whole of a per-entry export" absolutism; §14 M5 paragraph notes the articles exit shipping here. - Decision 1: style guide §2/§11 amber wording widens to actionable-attention contexts. Keep both docs' voice; cite the spec. - Blocked-by: bm3w79o (Implement MarkdownExport renderer, input types, and filename API)
The only intended visual change from the review fixes: the extension's error banner border moved from amber .35 to the unified .45 token. Worth one glance on device if you have strong feelings about the old weight.
Chapter chips are now amber and work chips cyan — including the name previews under them. This matches the style guide but inverts what shipped before this branch; a long-time user will notice.
To land the efficiency fix: retitle MarkdownExportModelTests' scavenge test, add await model.startExport(), and move the scavengeStaleFiles() call out of init. One small deliberate change, forbidden to this review by its own no-test-edit constraint.
Merge's validator gate still compares against nil, so works on a hostname carrying a tolerated diagnosis remain un-mergeable — the exact shape Q34 fixed for deletion. Out of M5 scope; a natural small follow-up.
The iPhone 17 Pro simulator intermittently crashes SimRenderServer mid-UI-run (signal kill, no assertion). Documented in docs/agent-notes/testing.md; the iPhone 17 device is the reliable fallback. Not a code issue.