asterism branch T-2303/site-display-names commits 20 files 46 touched lines +2060 / -137

Pre-push review: T-2303/site-display-names

Site display names: a rename on the site's settings screen, one name rule across the Sites read, export and the archive projection, and the display label on every reader-facing surface through a SiteNames environment value. Reviewed against the merge base with origin/main; four parallel passes, ten fixes applied.

At a glance

  • SiteName now owns trim, validate, stored form, custom-name predicate and the name rule; import shares the predicate (Q25).
  • renameSite writes every Site row for the hostname and saves nothing on a no-op; unknown hostname is invalidInput.
  • siteNames() feeds a SiteNames lookup published per refresh and injected as the app's first custom environment value, now also on the Mac Settings scene.
  • Every listed surface shows the label; identifiers, tags, filter keys, glyph colour and destructive confirmations stay hostname-keyed.
  • Site detail gains a Name section, a Rename button and a hostname-labelled link row; the list and the lookup refresh without relaunch.
  • Core, unit and the Sites UI class green after fixes; the full UI suite passed on the pre-fix source tree bar the three known M4Scale cases.

Verdict

Ready to push

No major or blocking findings. Every requirement in the smolspec traces to code and a test; the ten review fixes are tidy-ups (rule ownership, a shared ordering helper, the Mac Settings scene's environment write, one test gap) and make test-core, make test-quick and the Sites UI class pass after them with no new warnings. Rebase onto origin/main before opening the PR.

Review findings

16 raised · 10 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Asterism keeps a list of the websites a reader captures stories from. Until now every screen called a site by its web address, such as royalroad.com. A reader can now give a site a friendlier name from that site's own settings page, and the app shows that name everywhere it mentions the site: the works list, a work's detail page, the filter menu, the statistics page, an entry's capture details, and what VoiceOver reads aloud. The site's settings page also gains a link that opens the site in the browser.

Why it matters

Web addresses are not how people think about the places they read. The reader sees the name they chose in every place, without remembering the address. If two sites end up with the same name, the app adds the address in brackets so they never look identical.

Key concepts

  • Hostname: the address part of a link. In this app it is the site's identity: nothing changes it, and everything that tells sites apart uses it.
  • Display name: the label the reader sees. It already existed as a stored field, defaulting to the hostname. This change adds the first way to set it.
  • Lookup: a small table built after every refresh, mapping each hostname to the name to show. Screens read the table instead of asking the database. Think of it as a name tag every site wears for the rest of the app.
  • Environment value: SwiftUI's way of handing one value to every screen below a point without passing it through each screen by hand. The lookup travels this way.

Changes overview

  • AsterismCore: SiteName (new) owns trimming, validation, the stored form (empty resets to the hostname) and the name rule. LibraryProviding gains renameSite(hostname:to:) returning SiteRenameOutcome and a siteNames() read. sites(), the markdown export site line and SiteUnionProjection all use the one name rule.
  • App layer: SiteNames wraps the read with name(for:) and label(for:); AppLibraryModel.refreshAll publishes it beside the snapshots and ContentView (plus the Mac Settings scene) sets it as the \.siteNames environment value. SiteLabel and the other listed surfaces read it.
  • Sites screen: a Name section and a Link row; SiteDetailModel owns the draft, canRename, rename(), currentName and siteURL; an onChanged closure reloads the list behind the screen.
  • Tests: core tests for every rename arm and the three name-rule arms, app unit tests for the lookup, filter sort and detail model, and a UI journey renaming both seeded sites.

Implementation approach

The rename is a repository write under the exclusive lock, shaped like renameWorkType. It fetches every Site row for the hostname and writes each one: a hostname can have several rows (CloudKit merges mint duplicates a reconciler later consolidates) and the winning row is content-dependent, so writing only the winner would let a later teach flip the visible name. A rename to the value every row already holds returns before saving, so a no-op produces no sync traffic.

Reading goes through one rule: the first custom name among the hostname's rows in resolution order, else the hostname. "Custom" means non-blank after trimming and different from the hostname, because the constructor and import have always used the hostname to mean unnamed. Names reach views as an environment value; SiteLabel reads it itself, so its three callers did not change. The lookup computes its shared-name set once, so label(for:) is two hash lookups per call.

Trade-offs

  • Lookup versus snapshot field (Q2): stamping names onto snapshots would touch five repository signatures, about 25 call sites and two budgeted reads. The lookup is one small read per refresh, published under the same generation guard as the snapshots.
  • Environment value versus threading (Q3): eight views on independent routes name a site; one environment entry beat eight initialiser changes.
  • No uniqueness (Q5): sites are keyed by hostname, so a shared name is a presentation collision, solved by appending the hostname outside the Sites list.
  • Every row written (Q7) rather than giving the reconciler a write to a synced column.

Technical deep dive

  • Name rule and reset agree by construction. SiteName.stored is what a rename writes; SiteName.isCustom is the predicate the name rule, the no-op check and import precedence all share (Q25). A site reset to its hostname is unnamed to import again (Q8), which is the intended precedence.
  • Resolution order reused, not reimplemented. SiteResolutionOrder.orderedByHostname backs winnersByHostname, sites() and siteNames(); fetchSites and SiteUnionProjection sort the same way, so "winner first" is the same row everywhere. The winner supplies teaching state, all rows supply the name.
  • No-op detection uses the save strategy. withLockedContext builds a fresh ModelContext per call, so the core test injects an instrumented save strategy and asserts zero attempts.
  • Concurrency. Under the project's default main-actor isolation, SiteNames is nonisolated (Q23) because the nonisolated WorksFilterPresentation.activeLabels calls it. It is a pure Sendable value, so this is the honest declaration.
  • Environment coverage. Both navigation trees push entry detail from outside the screens AppScreens builds, so the write sits at the top of ContentView's ready content (Q18). The macOS Settings scene is built outside ContentView and now receives its own write, found by this review.
  • Identifiers stay hostname-keyed (Q10) so UI tests address rows by identity; accessibility labels read the display label (Q16).

Architecture impact

LibraryProviding grows by two requirements, stubbed in the mock's existing shape. SiteName is the single home for site-name semantics across repository, projection, import and app model. The environment-value pattern is now established for app-wide, read-only, per-refresh presentation data. No schema, archive-format or sync-attribute change; the archive value for a consolidated hostname can differ only for a loser-only custom name, which no library contains, and BackupGoldenExportTests bytes are unchanged.

Potential issues

  • Half-named duplicated hostname. If only a losing row is named, the screen shows that name and canRename stays false, so the rows converge only on a different rename. Harmless; the reconciler's next consolidation resolves it.
  • Concurrent renames on two devices converge last-writer-wins per row with no ordering evidence, as Site has no modification timestamp. Accepted for a single-reader library.
  • Two refreshes per rename (onMutation then onChanged), the second re-enumerating the membership table for counts. Once per tap; revisit only if renames feel slow.
  • Collision spelling. A site custom-named to another site's hostname wears the suffix; the site carrying that hostname does not (Q24).

Completeness assessment

Fully implemented: every Requirements bullet in the smolspec, and all three tiers of the Test Plan. Partially implemented: none. Missing: nothing from the spec; the Mac rendering of the Name section and link row is the owner's manual check.

Important changes — detailed

SiteName: one home for every site-name rule

Packages/AsterismCore/Sources/AsterismCore/SiteName.swift

Why it matters. Trim, validate, the stored form, the custom-name predicate and the name rule all live here and are shared by the rename, the Sites read, export, the archive projection and import. A drift between any two of them would silently misname a site.

What to look at. SiteName.swift: trimmed, validate, stored(_:hostname:), isCustom(_:hostname:), displayName(of:hostname:), SiteRenameOutcome

Takeaway. Put the semantics of a stored string in one enum and make every reader and writer call it, including the import path that predates the feature.
Rationale. Q4, Q6, Q13, Q15, Q25: an empty name is the reset, a case-only change is a rename, the forbidden set is shared with WorkTypeName but the validator is not, and import uses the same "unnamed" predicate.

renameSite writes every row and saves nothing on a no-op

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift

Why it matters. A hostname can have several Site rows and the winner is content-dependent. Writing only the winner would let a later teach revert the name; saving on a no-op would churn CloudKit.

What to look at. LibraryRepository+Sites.swift: renameSite(hostname:to:) under the exclusive lock

Takeaway. When a synced column can exist on duplicate rows, converge them at the write rather than teaching the reconciler about the column.
Rationale. Q7 and Q14 in the decision log; the no-op is asserted in tests through an injected save strategy because a fresh ModelContext per lock cannot be inspected afterwards.

One name rule over all rows, winner first

Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swift

Why it matters. Changes what the Sites list, export and the backup archive projection say about a hostname with duplicate rows. Golden export bytes are unchanged because no library holds a loser-only name today.

What to look at. IdentityResolution.swift: orderedByHostname and winnersByHostname; LibraryRepository+Sites.swift: sites() and siteNames(); SiteUnionProjection.swift: project

Takeaway. Read the same ordering helper everywhere a winner matters, and keep the whole ordered set when a second rule needs the losers too.
Rationale. Q6: a winner-only read would silently revert a name after a teach on a duplicated hostname.

SiteNames lookup as the first custom environment value

Asterism/Asterism/ViewModels/SiteNames.swift

Why it matters. Every reader-facing surface reads names from here. The shared-name set is computed once so label(for:) is safe in body passes, and the empty default degrades to hostnames.

What to look at. SiteNames.swift: name(for:), label(for:), EnvironmentValues.siteNames; AppLibraryModel.refreshAll publication; ContentView.swift and AsterismApp.swift writes

Takeaway. App-wide read-only presentation data that eight independent routes need is a good fit for an environment value; publish it under the same generation guard as the data it describes.
Rationale. Q2, Q3, Q18, Q23, Q24.

Surfaces read the label; identity stays on the hostname

Asterism/Asterism/Views/SiteLabel.swift

Why it matters. User-visible on every screen that names a site. Accessibility identifiers, picker tags, filter keys, glyph colour and destructive confirmations deliberately stay hostname-keyed so UI tests and confirmations cannot be broken by a rename.

What to look at. SiteLabel.swift, WorkDetailView.swift (picker and review menu), WorksView.swift and WorksListOptions.swift (filter, pill, row label), StatsView.swift, EntryDetailView.swift, RecentView.swift, WorkMergeView.swift

Takeaway. Separate identity from presentation explicitly: keys, colours and destructive sentences on the identity, labels on the presentation.
Rationale. Q10, Q12, Q16, Q19, Q20.

Site detail: rename section, link row, list reload

Asterism/Asterism/ViewModels/SitesModels.swift

Why it matters. The only write path for the feature. canRename mirrors the repository no-op through SiteName.stored, the title follows currentName, and onMutation plus onChanged refresh the lookup and the list without a relaunch.

What to look at. SitesModels.swift: SiteDetailModel draftName, currentName, canRename, rename(), siteURL; SitesView.swift Name section and Link row; SettingsView.swift, SettingsScreen.swift, AppLibraryModel.siteDetailModel threading

Takeaway. Predict the stored value in the model with the same function the repository uses, so the screen never shows a name the store did not write.
Rationale. Q9, Q21 (secondary button style, one gradient primary per screen), Q22 (the UI journey renames both seeded sites).

Key decisions

Smolspec, not a full spec (Q1).

Site.displayName is an existing stored, synced, archived column; no schema, archive-format or sync change, and the one user-owned question (which surfaces) was answered up front.

App-layer lookup rather than snapshot fields (Q2).

The snapshot route touches five signatures, about 25 call sites and two budgeted reads; the lookup is one small read per refreshAll and is additive to undo.

Environment value rather than threading (Q3, Q18).

Eight views on independent routes; one write at the top of ContentView, and after this review a second on the Mac Settings scene, which is built outside it.

No uniqueness; hostname appended on a shared name (Q5, Q24).

A site is its hostname, so a shared name is a presentation collision. A site still named by its own hostname never wears the suffix.

One name rule over all rows (Q6) and every row written (Q7).

The winner is content-dependent, so a winner-only read or write would revert a name after a teach on a duplicated hostname.

Empty resets; exact comparison; invalidInput for unknown hostname (Q4, Q13, Q14).

The hostname has always meant unnamed; no uniqueness means nothing to fold against; recordNotFound is UUID-keyed and a Site has none.

SiteNames is nonisolated (Q23).

Under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor the nonisolated activeLabels warned when calling it; the type is a pure Sendable value.

Import shares the custom-name predicate (Q25).

One definition of "unnamed" for the name rule, the reset and import precedence; the only input the old spellings disagreed on was a whitespace-only stored name, which nothing writes.

Secondary style on the Rename button (Q21).

Req 9.1 of polish-and-export allows one gradient primary per screen, and the site screen's is the re-teach.

UI journey renames both seeded sites (Q22).

The fixture has no Work on composed.test; id.test carries the work-detail check without a fixture change.

Review findings

SeverityAreaFindingResolution
minorreuse: reset ruleThe empty-resets-to-hostname rule was spelled in both renameSite and SiteDetailModel.resolvedDraftName.Added SiteName.stored(_:hostname:) and called it from both.
minorquality: name rule placementThe pure name rule lived on LibraryRepository and SiteUnionProjection reached into the repository type for it; SiteRenameOutcome sat apart from SiteNameRejection.Moved to SiteName.displayName(of:hostname:) with SiteRenameOutcome beside SiteNameRejection; four callers repointed.
minorreuse: grouped ordered rowssites() and siteNames() each re-derived Dictionary(grouping:) plus SiteResolutionOrder.sorted, the body of winnersByHostname.Added SiteResolutionOrder.orderedByHostname; winnersByHostname, sites() and siteNames() use it.
minorreuse: unnamed predicateImport decided "unnamed" as isEmpty or equals hostname while the name rule used trimmed-empty or equals hostname.Import now calls !SiteName.isCustom; recorded as Q25. No import test pinned a whitespace-only name.
minorspec: Mac Settings sceneThe macOS Settings scene is built outside ContentView and never received the siteNames environment value; harmless today but Q18 overstated coverage.Added the environment write on the Settings scene and amended Q18 and implementation.md.
minorspec: unrecorded divergencelabel(for:) suppresses the suffix for a site named by its own hostname; correct and tested but only recorded in a code comment.Recorded as Q24.
minortests: export wiringNo export test seeded two rows on one hostname, so SiteLookupCache.rows with a loser-only name was unexercised.Added a loser-only-name export test in ExportInputReadTests.
minorreuse: UI test helpergoBack() in SitesSettingsUITests was a third copy of the navigation-bar back tap.Moved to the XCUIApplication extension in UIJourneySupport; other suites left untouched per the test-file constraint.
nitefficiency: filter sortFilter option labels were recomputed inside the sort comparator.Labels computed once into pairs before sorting.
nittests: doc commentm5SiteNames carried both its own and m5SiteWinnerNames' doc comment.Comments split.
minorquality: WorksFilterOptionsWorksFilterOptions could carry site labels itself instead of taking SiteNames at three call sites.Skipped: the signature change is constructed by existing tests, and the current shape matches the smolspec. Candidate for a follow-up.
minorquality: onChanged threadingThe onChanged closure is threaded through five files where an internal reload or onAppear could do.Skipped: the smolspec chose this threading explicitly and the list model is view-owned.
nitefficiency: double trimvalidate trims and renameSite trims again.Skipped: two small allocations per tap, off any budgeted path.
nitefficiency: two refreshes per renameonMutation and onChanged each refresh; the second re-enumerates memberships for counts.Skipped: once per explicit tap; revisit only if renames feel slow.
nitquality: canRename on half-named duplicatesWhen only a losing row is named the button is disabled although the repository would converge the rows.Skipped: the visible name is already correct and consolidation resolves the rows.
nittests: list order after renameThe UI journey asserts the renamed row's text, not its new sorted position.Skipped: the read-side sort is unit-tested.

Per-file diffs

Click to expand.

Asterism/Asterism/AsterismApp.swift Modified +3 / -0
diff --git a/Asterism/Asterism/AsterismApp.swift b/Asterism/Asterism/AsterismApp.swiftindex 19c7279..f796d4a 100644--- a/Asterism/Asterism/AsterismApp.swift+++ b/Asterism/Asterism/AsterismApp.swift@@ -66,6 +66,9 @@ struct AsterismApp: App {             NavigationStack {                 SettingsScreen(model: model, navigation: navigation, dismissSheet: nil)             }+            // Q18: this scene is built outside `ContentView`, so it inherits+            // nothing from that write — the lookup is put in here too.+            .environment(\.siteNames, model.siteNames)         }         .defaultSize(width: 640, height: 560)         #else
Asterism/Asterism/ContentView.swift Modified +10 / -0
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex 949e731..96165c2 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -481,6 +481,16 @@ struct ContentView: View {     /// the two trees leaves a presented sheet where it is (Req 1.7).     private var readyContent: some View {         settingsPresentation(presentedContent)+            // `site-display-names` Q3: what every screen calls a site by.+            //+            // One write, at the top of the window rather than on each screen+            // `AppScreens` builds. Both trees declare their+            // `navigationDestination`s *outside* those screens — entry detail is+            // pushed from `screens.recent()`'s call site, not from inside it —+            // so a per-screen write would miss a surface on the list. From here+            // it reaches both trees, everything they push, and the sheets, which+            // inherit the presenting view's environment.+            .environment(\.siteNames, model.siteNames)     }      private var presentedContent: some View {
Asterism/Asterism/Layout/SettingsScreen.swift Modified +2 / -2
diff --git a/Asterism/Asterism/Layout/SettingsScreen.swift b/Asterism/Asterism/Layout/SettingsScreen.swiftindex 1e30382..e32d4d6 100644--- a/Asterism/Asterism/Layout/SettingsScreen.swift+++ b/Asterism/Asterism/Layout/SettingsScreen.swift@@ -72,8 +72,8 @@ struct SettingsScreen: View {             // 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+            siteDetailModel: { site, onChanged in+                model.siteDetailModel(for: site, onChanged: onChanged) { hostname, permits in                     navigation.pendingRoute = .reteach(                         PendingReteach(                             hostname: hostname,
Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +31 / -8
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 1b49a5f..6048c5e 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -36,6 +36,15 @@ public final class AppLibraryModel {     /// once per keystroke of the search field. Internal rather than `public`     /// because `WorksFilterOptions` is an app-layer presentation type.     private(set) var worksFilterOptions: WorksFilterOptions = .empty+    /// What every site in the library is called, published with the snapshots of+    /// the cycle that read it (`site-display-names` Q2).+    ///+    /// Handed to the screens as an environment value rather than threaded+    /// through eight initialisers (Q3). Internal rather than `public` for+    /// `worksFilterOptions`' reason: `SiteNames` is an app-layer presentation+    /// type. Empty until the first refresh completes, which is the same+    /// hostnames-only reading every surface had before this feature.+    private(set) var siteNames: SiteNames = .empty     /// How many refresh cycles have completed, bumped once per cycle with the     /// snapshots it publishes (`stats-page` Q27, Q29, Q37).     ///@@ -827,7 +836,7 @@ public final class AppLibraryModel {     /// Replaces all cached snapshots from the repository, publishing them     /// together or not at all (`stats-page` Q37).     ///-    /// The two reads remain two store instants — combining them would need one+    /// The reads remain separate store instants — combining them would need one     /// locked read in `AsterismCore`, which `stats-page` Q13 records as out of     /// scope, and `specs/immutable-capture-safety-net/implementation.md:60`     /// already discloses that tabs can briefly show different source moments.@@ -851,6 +860,11 @@ public final class AppLibraryModel {             let calendar = Calendar.current             let presentation = try await repo.recentPresentation(calendar: calendar)             let works = try await repo.works()+            // `site-display-names` Q2: the name every screen calls a site by,+            // read here beside the snapshots so the lookup a reader sees belongs+            // to the same cycle as the rows it names. One Site fetch, and no+            // membership enumeration — cheap enough to sit on this path.+            let siteNames = SiteNames(try await repo.siteNames())             // Keep the legacy grouped snapshot for curation views while deriving it             // from the same coherent Recent read rather than a second repository read.             let groups = presentation.groups.map {@@ -872,9 +886,9 @@ public final class AppLibraryModel {             // whose refresh loses this race and leaves a just-committed change             // unshown until the next one — a stale screen, self-correcting on             // the following refresh. It introduces no *new* mixing of moments:-            // the four values published are still one cycle's, and the two-            // reads inside a cycle remain two store instants exactly as Q13-            // already discloses.+            // the values published are still one cycle's, and the reads inside+            // a cycle remain separate store instants exactly as Q13 already+            // discloses.             //             // A re-read loop would close it, and is deliberately not taken: a             // CloudKit hydration republishes dozens of times (Q14 records ~45@@ -890,16 +904,19 @@ public final class AppLibraryModel {                 return             }             // No `await` from here to the end of the block: a reader observes-            // all four values of one cycle, never three of this one beside one-            // of the last.+            // every value of one cycle, never most of this one beside one of the+            // last.             recentPresentation = presentation             recentGroups = groups             worksSnapshot = works+            self.siteNames = siteNames             workTitlesByID = Dictionary(                 works.works.map { ($0.id, $0.displayTitle) }, uniquingKeysWith: { first, _ in first })             // Derived from the **full** snapshot, before any search or filter:             // the options never narrow as other dimensions are chosen (Q12).-            worksFilterOptions = WorksFilterOptions(works: works.works)+            // The lookup is passed in rather than read back off `self`, so the+            // options are sorted by the names published beside them.+            worksFilterOptions = WorksFilterOptions(works: works.works, siteNames: siteNames)             snapshotGeneration += 1         } catch {             Self.logger.error("Snapshot refresh failed: \(String(describing: error), privacy: .public)")@@ -1099,8 +1116,13 @@ public final class AppLibraryModel {     /// 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.+    /// `onChanged` is the Sites list's own reload, so a rename is on the row the+    /// reader comes back to; `onMutation` re-reads the snapshots and with them+    /// the `siteNames` lookup, so every other surface shows the new name without+    /// a relaunch.     public func siteDetailModel(         for site: SiteSnapshot,+        onChanged: @escaping @MainActor () async -> Void,         onReteach: @escaping @MainActor (String, Bool) -> Void     ) -> SiteDetailModel? {         guard let repo = repository else { return nil }@@ -1109,7 +1131,8 @@ public final class AppLibraryModel {             library: repo,             canReteach: canComposeTeaching(forHostname: site.hostname),             onReteach: onReteach,-            onMutation: { [weak self] in await self?.refreshDiagnosesAndSnapshots() })+            onMutation: { [weak self] in await self?.refreshDiagnosesAndSnapshots() },+            onChanged: onChanged)     }      /// The work-types list (configurable-work-types Req 1).
Asterism/Asterism/ViewModels/SiteNames.swift Added +78 / -0
diff --git a/Asterism/Asterism/ViewModels/SiteNames.swift b/Asterism/Asterism/ViewModels/SiteNames.swiftnew file mode 100644index 0000000..aa714fd--- /dev/null+++ b/Asterism/Asterism/ViewModels/SiteNames.swift@@ -0,0 +1,78 @@+import SwiftUI++/// What every site in the library is called, read once per snapshot refresh and+/// handed to the screens that name one (`site-display-names` Q2).+///+/// The map is `LibraryRepository.siteNames()`'s: every stored hostname against+/// the name resolved by the one name rule, which is the hostname itself where+/// no row carries a custom name. A hostname the map has never heard of — a+/// snapshot published before the site's first refresh, or a lookup that is+/// simply empty — reads as itself, so an unset environment value degrades to+/// exactly the hostnames every surface showed before this feature.+///+/// Two sites may share a name (Q5): names are not unique and nothing enforces+/// that they should be, because a site *is* its hostname and the name is+/// presentation. So a name more than one hostname resolves to is spelled with+/// its hostname appended wherever it is shown outside the Sites list, and no two+/// sites can read alike on one surface. The shared set is computed once here+/// rather than per row: `label(for:)` is called once per option in a filter menu+/// and once per membership on a row, and a scan of the whole map at each of+/// those is a library-sized loop inside a body pass.+///+/// `nonisolated` (Q23): this is a pure value read from wherever a site is named,+/// and one of those places — `WorksFilterPresentation.activeLabels` — is itself+/// `nonisolated`. Under the project's `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`+/// an unannotated `label(for:)` is main-actor isolated, and that call warns.+nonisolated struct SiteNames: Equatable, Sendable {++    /// The empty lookup: every hostname reads as itself. Both the environment+    /// default and what `AppLibraryModel` publishes before its first refresh.+    static let empty = SiteNames([:])++    private let namesByHostname: [String: String]++    /// The resolved names that more than one hostname in the map shares.+    private let sharedNames: Set<String>++    init(_ namesByHostname: [String: String]) {+        self.namesByHostname = namesByHostname+        var seen: Set<String> = []+        var shared: Set<String> = []+        for name in namesByHostname.values where !seen.insert(name).inserted {+            shared.insert(name)+        }+        self.sharedNames = shared+    }++    /// The site's display name, or the hostname where the lookup does not know+    /// the hostname.+    func name(for hostname: String) -> String {+        namesByHostname[hostname] ?? hostname+    }++    /// What a surface outside the Sites list spells the site: the display name,+    /// with the hostname appended where another site in the library resolves to+    /// the same name (Q5).+    ///+    /// A site still named by its own hostname never gets the suffix — it would+    /// read `example.test (example.test)`, and it is already the one spelling in+    /// the collision that says which site it is.+    func label(for hostname: String) -> String {+        let name = name(for: hostname)+        guard name != hostname, sharedNames.contains(name) else { return name }+        return "\(name) (\(hostname))"+    }+}++extension EnvironmentValues {+    /// The app's first custom environment entry (Q3).+    ///+    /// Eight views on independent routes name a site, and threading a read-only+    /// map through each screen's initialiser would change eight signatures to+    /// carry one value none of them acts on. `SiteLabel` reading it itself is+    /// what leaves its three callers untouched.+    ///+    /// The default is the empty lookup, so a presentation path that never+    /// receives one shows hostnames rather than nothing.+    @Entry var siteNames: SiteNames = .empty+}
Asterism/Asterism/ViewModels/SitesModels.swift Modified +91 / -1
diff --git a/Asterism/Asterism/ViewModels/SitesModels.swift b/Asterism/Asterism/ViewModels/SitesModels.swiftindex 7dd8e5f..4e8b119 100644--- a/Asterism/Asterism/ViewModels/SitesModels.swift+++ b/Asterism/Asterism/ViewModels/SitesModels.swift@@ -99,6 +99,14 @@ public final class SiteDetailModel {     }      public let site: SiteSnapshot+    /// Opens on the stored name, because a rename is nearly always a correction+    /// of it (the `WorkTypeDetailModel` precedent).+    public var draftName: String+    /// The name the screen is currently showing the site under — the snapshot's+    /// at first, then whatever the last successful rename resolved to. The+    /// navigation title follows it, because unlike the mode changes below a+    /// rename leaves the reader on this screen.+    public private(set) var currentName: String     public private(set) var articlesPrompt: ArticlesPrompt?     public private(set) var errorMessage: String?     public private(set) var isSubmitting = false@@ -108,6 +116,7 @@ public final class SiteDetailModel {     private let library: any LibraryProviding     private let onReteach: @MainActor (String, Bool) -> Void     private let onMutation: @Sendable () async -> Void+    private let onChanged: @MainActor () async -> Void      /// Whether a re-teach can be entered at all.     ///@@ -122,13 +131,17 @@ public final class SiteDetailModel {         library: any LibraryProviding,         canReteach: Bool,         onReteach: @escaping @MainActor (String, Bool) -> Void,-        onMutation: @escaping @Sendable () async -> Void+        onMutation: @escaping @Sendable () async -> Void,+        onChanged: @escaping @MainActor () async -> Void     ) {         self.site = site+        self.draftName = site.displayName+        self.currentName = site.displayName         self.library = library         self.canReteach = canReteach         self.onReteach = onReteach         self.onMutation = onMutation+        self.onChanged = onChanged     }      // MARK: - Wording@@ -164,6 +177,71 @@ public final class SiteDetailModel {     /// the switch; its way out is the re-teach above (Decision 3).     public var canSwitchToArticles: Bool { site.mode != .articles } +    // MARK: - Rename++    /// What the rename would settle on: the trimmed draft, or the hostname where+    /// the reader emptied the field, because an empty name is the reset (Q4) and+    /// not a refusal.+    ///+    /// Through `SiteName.stored`, the same definition the repository writes by,+    /// so the screen cannot predict a name the store would not settle on.+    private var resolvedDraftName: String {+        SiteName.stored(draftName, hostname: site.hostname)+    }++    /// False on a no-op, so the button is not offered for a rename that would+    /// write nothing — including the empty field on a site already under its own+    /// hostname, which resolves to the name it already has.+    ///+    /// The comparison is exact (Q13): site names have no uniqueness rule, so+    /// there is nothing to fold against and a case-only change is a rename.+    public var canRename: Bool {+        resolvedDraftName != currentName && !isSubmitting+    }++    /// Sets the name this site presents under everywhere (Req 6 of this feature).+    ///+    /// Unlike the mode changes below, this leaves the reader on the screen: the+    /// title follows `currentName`, and the hosts are told so the list behind+    /// this screen and every other surface show the new name without a relaunch.+    public func rename() async {+        guard canRename else { return }+        isSubmitting = true+        errorMessage = nil+        defer { isSubmitting = false }+        do {+            switch try await library.renameSite(hostname: site.hostname, to: draftName) {+            case .renamed:+                currentName = resolvedDraftName+                // The field shows what was stored, not what was typed: a reset+                // spells out the hostname the site is now under.+                draftName = currentName+                await onMutation()+                await onChanged()+            case .rejected(let rejection):+                errorMessage = SiteNameRejectionPresentation.sentence(for: rejection)+            }+        } catch {+            errorMessage = error.localizedDescription+            Self.logger.error(+                "Site rename failed: \(String(describing: error), privacy: .public)")+        }+    }++    // MARK: - The site's own page++    /// Where the screen's link goes, or nil where no URL can be built from the+    /// hostname — in which case the screen shows no row rather than an inert one+    /// (the `WorkDetailModel.siteLinks` precedent).+    public var siteURL: URL? { Self.siteURL(hostname: site.hostname) }++    /// `https://<hostname>/`, the site's front page. A Site stores only a+    /// hostname, so there is nothing more specific to open, and the link is+    /// labelled with the hostname rather than the name for the same reason (Q9).+    public static func siteURL(hostname: String) -> URL? {+        URL(string: "https://\(hostname)/")+    }+     // MARK: - Re-teach (Req 6.2, 6.3)      /// Routes to the composed teaching surface.@@ -238,6 +316,18 @@ public final class SiteDetailModel {     } } +/// The one place a refused site name is turned into words, following the+/// `WorkTypeRejectionPresentation` convention: the repository answers with a+/// reason, the screen shows a sentence.+enum SiteNameRejectionPresentation {+    static func sentence(for rejection: SiteNameRejection) -> String {+        switch rejection {+        case .containsLineBreaksOrControlCharacters:+            "A site name is a single line of text, without line breaks or control characters."+        }+    }+}+ /// 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 {
Asterism/Asterism/ViewModels/WorksListOptions.swift Modified +36 / -5
diff --git a/Asterism/Asterism/ViewModels/WorksListOptions.swift b/Asterism/Asterism/ViewModels/WorksListOptions.swiftindex 9cb9328..fdcc77f 100644--- a/Asterism/Asterism/ViewModels/WorksListOptions.swift+++ b/Asterism/Asterism/ViewModels/WorksListOptions.swift@@ -214,6 +214,9 @@ struct WorksFilterOptions: Equatable, Sendable {      let types: [TypeOption]     let tags: [String]+    /// The site options, keyed by hostname — the filter's key is identity, not+    /// presentation (`site-display-names` Q10) — but ordered by the label the+    /// menu spells them with, so the rows read in the order they are sorted in.     let hostnames: [String]      /// What to call a type selection — the option's own spelling where the@@ -237,7 +240,12 @@ struct WorksFilterOptions: Equatable, Sendable {         self.hostnames = hostnames     } -    init(works: [WorkSnapshot]) {+    /// The vocabularies one snapshot offers, ordered as the menu shows them.+    ///+    /// `siteNames` is stated rather than defaulted: the site options are sorted+    /// by their labels, so a caller that forgot to pass the lookup would publish+    /// options ordered by hostname while the menu spelled them by name.+    init(works: [WorkSnapshot], siteNames: SiteNames) {         // One record per option: the first spelling seen, and whether every         // work so far wore a removed type.         var typeRecords: [WorksTypeSelection: (name: String, allRemoved: Bool)] = [:]@@ -273,7 +281,26 @@ struct WorksFilterOptions: Equatable, Sendable {                 }             }         self.tags = tags.sorted { $0.localizedStandardCompare($1) == .orderedAscending }-        self.hostnames = hostnames.sorted { $0.localizedStandardCompare($1) == .orderedAscending }+        // By label, hostname as tiebreak (`site-display-names` Req 11), which+        // matches the Sites list's order. Two labels can only compare the same+        // where two hostnames carry the *same* custom name and neither wears the+        // disambiguating suffix — unreachable today, but the tiebreak keeps the+        // order deterministic rather than dependent on the set's iteration.+        //+        // The label is looked up once per hostname rather than on every+        // comparison the sort makes.+        self.hostnames = hostnames+            .map { (hostname: $0, label: siteNames.label(for: $0)) }+            .sorted { left, right in+                switch left.label.localizedStandardCompare(right.label) {+                case .orderedAscending: return true+                case .orderedDescending: return false+                case .orderedSame:+                    return left.hostname.localizedStandardCompare(right.hostname)+                        == .orderedAscending+                }+            }+            .map(\.hostname)     } } @@ -286,12 +313,16 @@ nonisolated enum WorksFilterPresentation {      /// The active values in menu order — type, then tag, then site — spelled as     /// the pickers spell them. A type is named by the option it came from, so a-    /// merged type's pill wears the same first-seen spelling its menu row does.-    static func activeLabels(_ filter: WorksFilter, options: WorksFilterOptions) -> [String] {+    /// merged type's pill wears the same first-seen spelling its menu row does,+    /// and a site is named by the lookup for the same reason: the pill is the+    /// reader reading back the row they picked.+    static func activeLabels(+        _ filter: WorksFilter, options: WorksFilterOptions, siteNames: SiteNames+    ) -> [String] {         var labels: [String] = []         if let type = filter.type { labels.append(options.name(for: type)) }         if let tag = filter.tag { labels.append(tag) }-        if let hostname = filter.hostname { labels.append(hostname) }+        if let hostname = filter.hostname { labels.append(siteNames.label(for: hostname)) }         return labels     } 
Asterism/Asterism/Views/EntryDetailView.swift Modified +23 / -2
diff --git a/Asterism/Asterism/Views/EntryDetailView.swift b/Asterism/Asterism/Views/EntryDetailView.swiftindex c7d9819..4fb7aac 100644--- a/Asterism/Asterism/Views/EntryDetailView.swift+++ b/Asterism/Asterism/Views/EntryDetailView.swift@@ -17,6 +17,9 @@ import SwiftUI struct EntryDetailView: View {     @State private var model: EntryDetailModel     @Environment(\.dismiss) private var dismiss+    /// `site-display-names`: the capture details name the site, with the+    /// hostname beneath it where the two differ.+    @Environment(\.siteNames) private var siteNames     @State private var showingComposedTeaching = false     @State private var showingReparse = false     /// Q49: collapsed by default. Capture evidence and the recovery actions are@@ -384,11 +387,29 @@ struct EntryDetailView: View {     private func captureDetailsSection(_ entry: EntrySnapshot) -> some View {         Section {             DisclosureGroup(isExpanded: $showingCaptureDetails) {+                // The site's name, with the hostname under it where the two+                // differ. This is the one surface the requirement asks to keep+                // the hostname *beside* the name rather than only in a+                // collision, because capture details are where identity is+                // being read — so the name alone is enough here, and Q5's+                // appended-hostname spelling would say the hostname twice.                 LabeledContent("Site") {                     HStack(spacing: 6) {                         SiteGlyph(hostname: entry.hostname, size: 18)-                        Text(entry.hostname)-                            .foregroundStyle(AsterismColors.secondaryText)+                        VStack(alignment: .leading, spacing: 2) {+                            let name = siteNames.name(for: entry.hostname)+                            Text(name)+                                .foregroundStyle(AsterismColors.secondaryText)+                                .lineLimit(1)+                                .truncationMode(.tail)+                            if name != entry.hostname {+                                Text(entry.hostname)+                                    .font(.caption)+                                    .foregroundStyle(AsterismColors.secondaryText)+                                    .lineLimit(1)+                                    .truncationMode(.tail)+                            }+                        }                     }                 }                 .accessibilityIdentifier("entry-detail-hostname")
Asterism/Asterism/Views/RecentView.swift Modified +10 / -3
diff --git a/Asterism/Asterism/Views/RecentView.swift b/Asterism/Asterism/Views/RecentView.swiftindex 1a6b5d3..4c2bf73 100644--- a/Asterism/Asterism/Views/RecentView.swift+++ b/Asterism/Asterism/Views/RecentView.swift@@ -773,6 +773,11 @@ struct RecentDuplicatePlan: Equatable { /// raw capture title for untaught. Actionable rows show amber treatment with /// inline Teach/Re-teach pill that opens the composed teaching surface directly. struct RecentEntryRow: View {+    /// `site-display-names` Q16: VoiceOver hears the site the way a sighted+    /// reader reads it. The row draws no site text of its own — this is the+    /// accessibility sentence only.+    @Environment(\.siteNames) private var siteNames+     let row: RecentPresentationRow     /// `ipad-and-mac-layouts` Req 1.5: this row's entry is the one open in the     /// detail column beside the list. False in the compact tree, which pushes@@ -842,7 +847,8 @@ struct RecentEntryRow: View {             .buttonStyle(.plain)             .frame(maxWidth: .infinity, alignment: .leading)             .accessibilityIdentifier("recent-entry-\(row.id.uuidString)")-            .accessibilityLabel("Open entry \(accessibilityTitle) from \(row.hostname)")+            .accessibilityLabel(+                "Open entry \(accessibilityTitle) from \(siteNames.label(for: row.hostname))")              // Req 9.2: the same pill shape as Teach, deliberately — one inline             // affordance pattern, so a reader who has learned one has learned@@ -959,8 +965,9 @@ struct RecentEntryRow: View {              HStack(spacing: 6) {                 // 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.+                // the glyph instead — coloured from the hostname, so a rename+                // never recolours it. The site is still spoken: the row+                // button's accessibility label names it, by its display label.                 SiteGlyph(                     hostname: row.hostname,                     kind: row.workDisplayTitle == nil ? .unknown : .site,
Asterism/Asterism/Views/SettingsView.swift Modified +2 / -2
diff --git a/Asterism/Asterism/Views/SettingsView.swift b/Asterism/Asterism/Views/SettingsView.swiftindex d110fee..4378a2a 100644--- a/Asterism/Asterism/Views/SettingsView.swift+++ b/Asterism/Asterism/Views/SettingsView.swift@@ -44,7 +44,7 @@ struct SettingsView: View {     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?)?+    private let siteDetailModel: SiteDetailModelBuilder?     /// The work-types list (configurable-work-types Req 1.1). A plain `let` like     /// `sitesModel`: the screen it pushes owns the loaded state, and it builds     /// its own per-type detail models.@@ -83,7 +83,7 @@ struct SettingsView: View {         syncModel: SettingsSyncModel? = nil,         interruptedImportNotice: String? = nil,         sitesModel: SitesListModel? = nil,-        siteDetailModel: (@MainActor (SiteSnapshot) -> SiteDetailModel?)? = nil,+        siteDetailModel: SiteDetailModelBuilder? = nil,         workTypesModel: WorkTypesModel? = nil,         pendingCaptureWaitingNotice: String? = nil,         setAsideCaptures: [SetAsideCaptureRow] = [],
Asterism/Asterism/Views/SiteLabel.swift Modified +20 / -8
diff --git a/Asterism/Asterism/Views/SiteLabel.swift b/Asterism/Asterism/Views/SiteLabel.swiftindex 1528bd5..97f1eb2 100644--- a/Asterism/Asterism/Views/SiteLabel.swift+++ b/Asterism/Asterism/Views/SiteLabel.swift@@ -1,19 +1,28 @@ import ConstellationKit import SwiftUI -/// A site's glyph beside its hostname — the shape every per-site row draws+/// A site's glyph beside its name — the shape every per-site row draws /// (`multi-site-works` Reqs 4.1, 6.1). /// /// One view rather than one per screen: a Work on three sites now shows the /// same row on the work page, in the merge picker and in the merge preview, and-/// three copies of "glyph, then hostname" is three places for the spacing and-/// the glyph size to drift apart.+/// three copies of "glyph, then name" is three places for the spacing and the+/// glyph size to drift apart. ///-/// The glyph is always hidden from accessibility: it is the hostname in colour,-/// and a reader using VoiceOver hears the hostname from the `Text`. Callers-/// that combine the row into one element (`accessibilityElement(children:)`)-/// still get exactly one reading of the site.+/// `site-display-names`: the text is the site's display label, read from the+/// environment here rather than passed in, so the three callers are unchanged+/// (Q3). The **hostname** is still what identifies the site — it is what the+/// glyph colours from (Q10, `polish-and-export` Req 9.2), so a rename never+/// recolours a site, and it is what callers key their identifiers on. Where no+/// site has been renamed the label *is* the hostname, and nothing here changes.+///+/// The glyph is always hidden from accessibility: it is the site in colour, and+/// a reader using VoiceOver hears the name from the `Text` (Q16). Callers that+/// combine the row into one element (`accessibilityElement(children:)`) still+/// get exactly one reading of the site. struct SiteLabel: View {+    @Environment(\.siteNames) private var siteNames+     let hostname: String     /// The glyph's edge. 18 pt is the list-row size; the work page's site line     /// draws it at 40.@@ -26,10 +35,13 @@ struct SiteLabel: View {             SiteGlyph(hostname: hostname, size: glyphSize)                 .accessibilityHidden(true) -            Text(hostname)+            // Q17: one line, tail-truncated, whatever the name's length — there+            // is no bound on it and this row is not where one would be enforced.+            Text(siteNames.label(for: hostname))                 .font(hostnameFont)                 .foregroundStyle(hostnameColor)                 .lineLimit(1)+                .truncationMode(.tail)         }     } }
Asterism/Asterism/Views/SitesView.swift Modified +50 / -6
diff --git a/Asterism/Asterism/Views/SitesView.swift b/Asterism/Asterism/Views/SitesView.swiftindex 32e748d..aca94b2 100644--- a/Asterism/Asterism/Views/SitesView.swift+++ b/Asterism/Asterism/Views/SitesView.swift@@ -2,6 +2,11 @@ import AsterismCore import ConstellationKit import SwiftUI +/// How a site's screen is built: the site it is for, plus the reload it calls+/// after a rename so the list behind it is not stale on return.+typealias SiteDetailModelBuilder =+    @MainActor (SiteSnapshot, @escaping @MainActor () async -> Void) -> SiteDetailModel?+ /// 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.@@ -9,11 +14,11 @@ 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?+    let detailModel: SiteDetailModelBuilder      init(         model: SitesListModel,-        detailModel: @escaping @MainActor (SiteSnapshot) -> SiteDetailModel?+        detailModel: @escaping SiteDetailModelBuilder     ) {         _model = State(initialValue: model)         self.detailModel = detailModel@@ -50,7 +55,10 @@ struct SitesListView: View {      private func siteRow(_ row: SitesListModel.Row) -> some View {         NavigationLink {-            if let detail = detailModel(row.site) {+            // The reload is this list's own: a rename on the detail screen+            // changes the name the row behind it draws, and the reader comes+            // straight back to it.+            if let detail = detailModel(row.site, { await model.load() }) {                 SiteDetailView(model: detail)             }         } label: {@@ -112,6 +120,28 @@ struct SiteDetailView: View {      var body: some View {         List {+            // The name the site presents under, in the `WorkTypeDetailView`+            // shape. The header stays the hostname: that is the identity, and+            // the field above it is only what the identity is called.+            Section {+                TextField("Name", text: $model.draftName)+                    .autocorrectionDisabled()+                    .noAutocapitalization()+                    .accessibilityIdentifier("site-detail-name-field")+                    .frame(minHeight: AsterismLayout.minHitTarget)+                // Deliberately not the gradient treatment the work-types screen+                // gives its rename: Req 9.1 allows one per screen, and this+                // screen's is the re-teach below.+                Button("Rename") {+                    Task { await model.rename() }+                }+                .buttonStyle(.constellationSecondary)+                .disabled(!model.canRename)+                .accessibilityIdentifier("site-detail-rename-button")+            } header: {+                ConstellationSectionHeader(model.site.hostname)+            }+             Section {                 LabeledContent("Mode") { Text(model.modeLabel) }                     .accessibilityIdentifier("site-detail-mode")@@ -119,8 +149,20 @@ struct SiteDetailView: View {                     .font(.caption)                     .foregroundStyle(.secondary)                     .accessibilityIdentifier("site-detail-mode-explanation")-            } header: {-                ConstellationSectionHeader(model.site.hostname)++                // Q9: labelled with the hostname, not the name — this is the one+                // place the screen says where the link goes. Absent entirely+                // where no URL can be built, rather than shown inert.+                if let url = model.siteURL {+                    Link(destination: url) {+                        Label(model.site.hostname, systemImage: "link")+                            .lineLimit(1)+                            .truncationMode(.tail)+                            .frame(minHeight: AsterismLayout.minHitTarget)+                    }+                    .accessibilityIdentifier("site-detail-link-\(model.site.hostname)")+                    .accessibilityLabel("Open \(model.site.hostname)")+                }             }              Section {@@ -161,7 +203,9 @@ struct SiteDetailView: View {                 }             }         }-        .navigationTitle(model.site.displayName)+        // The model's name, not the snapshot's: a rename leaves the reader here,+        // so the title has to follow it.+        .navigationTitle(model.currentName)         .inlineNavigationTitle()         .accessibilityIdentifier("site-detail-view")         // The consequences, then the choice — the same shape teach mode's own
Asterism/Asterism/Views/StatsView.swift Modified +7 / -1
diff --git a/Asterism/Asterism/Views/StatsView.swift b/Asterism/Asterism/Views/StatsView.swiftindex d241f43..6bc8f54 100644--- a/Asterism/Asterism/Views/StatsView.swift+++ b/Asterism/Asterism/Views/StatsView.swift@@ -63,6 +63,10 @@ struct StatsView: View {     /// Q54: the selected band's outline is an accessibility affordance, not a     /// standing part of the graph's look.     @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor+    /// `site-display-names`: "Most read sites" names its rows through the+    /// lookup. `StatsDerivation` still ranks by hostname — the ranking is over+    /// identities, and the name is what the row is spelled.+    @Environment(\.siteNames) private var siteNames      var body: some View {         stack@@ -703,7 +707,9 @@ struct StatsView: View {     /// Q4: a site is the note's capture hostname, and there is nothing to open —     /// no screen in this app is a site, so the row is the inert half only.     private func rankedSiteRow(_ row: StatsSiteRankingRow) -> some View {-        countRow(name: row.hostname, count: row.count, inertIdentifier: "stats-top-site-row")+        countRow(+            name: siteNames.label(for: row.hostname), count: row.count,+            inertIdentifier: "stats-top-site-row")     }      /// Checked once more at the moment of the tap, which is what Req 6.10 asks
Asterism/Asterism/Views/WorkDetailView.swift Modified +13 / -2
diff --git a/Asterism/Asterism/Views/WorkDetailView.swift b/Asterism/Asterism/Views/WorkDetailView.swiftindex b1b1c6f..5113774 100644--- a/Asterism/Asterism/Views/WorkDetailView.swift+++ b/Asterism/Asterism/Views/WorkDetailView.swift@@ -24,6 +24,9 @@ struct WorkDetailView: View {     @State private var model: WorkDetailModel     @Environment(\.dismiss) private var dismiss     @Environment(\.openURL) private var openURL+    /// `site-display-names`: what the site picker and the identity review menu+    /// spell their sites. The site row itself gets this from `SiteLabel`.+    @Environment(\.siteNames) private var siteNames     @State private var activeMergeModel: WorkMergeModel?     /// Which site the URL-identity review is being opened for, or nil when no     /// review is up. A hostname rather than a flag because the sheet is per site@@ -786,7 +789,13 @@ struct WorkDetailView: View {             if WorkDetailSitePresentation.showsSitePicker(hostnames: model.hostnames) {                 Picker("Site", selection: workURLHostnameBinding) {                     ForEach(model.hostnames, id: \.self) { hostname in-                        Text(hostname).tag(hostname)+                        // Named for the reader, tagged by hostname: the tag is+                        // the site's identity and the selection is written back+                        // against it (Q10).+                        Text(siteNames.label(for: hostname))+                            .lineLimit(1)+                            .truncationMode(.tail)+                            .tag(hostname)                     }                 }                 .disabled(model.isWorkURLSubmitting || model.isReadOnly)@@ -849,7 +858,9 @@ struct WorkDetailView: View {                 case .menu(let hostnames):                     Menu(WorkDetailView.reviewIdentityLabel) {                         ForEach(hostnames, id: \.self) { hostname in-                            Button(hostname) {+                            // The row names the site; the identifier still keys+                            // it by hostname (Q10).+                            Button(siteNames.label(for: hostname)) {                                 reviewingIdentityHostname = PresentedHostname(hostname: hostname)                             }                             .accessibilityIdentifier(
Asterism/Asterism/Views/WorkMergeView.swift Modified +12 / -3
diff --git a/Asterism/Asterism/Views/WorkMergeView.swift b/Asterism/Asterism/Views/WorkMergeView.swiftindex f26d0fa..aa6d3e1 100644--- a/Asterism/Asterism/Views/WorkMergeView.swift+++ b/Asterism/Asterism/Views/WorkMergeView.swift@@ -8,6 +8,10 @@ import SwiftUI struct WorkMergeView: View {     @State private var model: WorkMergeModel     @Environment(\.dismiss) private var dismiss+    /// `site-display-names` Q16: the destination row's spoken sentence names+    /// the same sites its `SiteLabel`s show. The preview's own site rows read+    /// the lookup through `SiteLabel` and need nothing here.+    @Environment(\.siteNames) private var siteNames      init(model: WorkMergeModel) {         _model = State(initialValue: model)@@ -125,16 +129,21 @@ struct WorkMergeView: View {         .disabled(unavailable != nil || !model.canSelectDestination)         .frame(minHeight: AsterismLayout.minHitTarget)         .accessibilityIdentifier("merge-destination-\(work.id.uuidString)")-        .accessibilityLabel(WorkMergeView.destinationLabel(work, unavailable: unavailable))+        .accessibilityLabel(+            WorkMergeView.destinationLabel(+                work, unavailable: unavailable, siteNames: siteNames))     }      /// One sentence per candidate, naming what the row shows: the title, the     /// sites, the type where it has one, the note count, and why it cannot be     /// chosen where it cannot.-    static func destinationLabel(_ work: WorkSnapshot, unavailable: String?) -> String {+    static func destinationLabel(+        _ work: WorkSnapshot, unavailable: String?, siteNames: SiteNames+    ) -> String {         var parts = ["Merge into \(work.displayTitle)"]         if !work.memberships.isEmpty {-            parts.append("on " + work.memberships.map(\.hostname).joined(separator: ", "))+            let sites = work.memberships.map { siteNames.label(for: $0.hostname) }+            parts.append("on " + sites.joined(separator: ", "))         }         if let typeName = work.typeDisplay.name { parts.append(typeName) }         parts.append(Pluralisation.count(work.entries.count, "note", "notes"))
Asterism/Asterism/Views/WorksView.swift Modified +24 / -7
diff --git a/Asterism/Asterism/Views/WorksView.swift b/Asterism/Asterism/Views/WorksView.swiftindex 24cb6fd..535dae5 100644--- a/Asterism/Asterism/Views/WorksView.swift+++ b/Asterism/Asterism/Views/WorksView.swift@@ -77,6 +77,10 @@ struct WorksView: View {         self.onDismissDuplicate = onDismissDuplicate     } +    /// `site-display-names`: what the site filter's rows and its active pill+    /// spell. The row keys stay hostnames (Q10); only the spelling moves.+    @Environment(\.siteNames) private var siteNames+     /// 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 = ""@@ -249,7 +253,13 @@ struct WorksView: View {                 anyIdentifier: WorksFilterPresentation.anySiteRowIdentifier,                 tag: \.self,                 identifier: WorksFilterPresentation.siteRowIdentifier-            ) { Text($0) }+            ) {+                // The option's key is the hostname — the identifier and the tag+                // both stay it (Q10) — and only the row's text is the name.+                Text(siteNames.label(for: $0))+                    .lineLimit(1)+                    .truncationMode(.tail)+            }         } label: {             // Req 6: filled while any filter is active. The sort alone does not             // change it — every list has a sort, so a permanently filled icon@@ -296,7 +306,8 @@ struct WorksView: View {     /// What the pills and the empty state both name — the active values in menu     /// order, spelled as the picker spells them.     private var activeFilterLabels: [String] {-        WorksFilterPresentation.activeLabels(filter, options: filterOptions)+        WorksFilterPresentation.activeLabels(+            filter, options: filterOptions, siteNames: siteNames)     }      /// Req 7's pill row, drawn only where the caller has established that a@@ -493,7 +504,8 @@ struct WorksView: View {             }             .buttonStyle(.plain)             .accessibilityIdentifier("work-row-\(work.id.uuidString)")-            .accessibilityLabel(WorksRowPresentation.openLabel(for: work))+            .accessibilityLabel(+                WorksRowPresentation.openLabel(for: work, siteNames: siteNames))              if let item, let onResolveDuplicate {                 Button {@@ -574,10 +586,15 @@ enum WorksRowPresentation {     ///     /// A Work with no membership at all (Req 8.1) is named without a site     /// rather than "from ", which is what a joined empty list would say.-    static func openLabel(for work: WorkSnapshot) -> String {-        let hostnames = work.memberships.map(\.hostname)-        guard !hostnames.isEmpty else { return "Open Work \(work.displayTitle)" }-        return "Open Work \(work.displayTitle) from \(hostnames.joined(separator: ", "))"+    ///+    /// `site-display-names` Q16: the sites are named by their labels, not their+    /// hostnames — the merge picker draws this same row with its `SiteLabel`s+    /// visible, and a sentence that spoke hostnames there would say something+    /// different from what is on screen.+    static func openLabel(for work: WorkSnapshot, siteNames: SiteNames) -> String {+        let sites = work.memberships.map { siteNames.label(for: $0.hostname) }+        guard !sites.isEmpty else { return "Open Work \(work.displayTitle)" }+        return "Open Work \(work.displayTitle) from \(sites.joined(separator: ", "))"     }      /// One "Not the same work" pill: the Work it names, and what to call it.
Asterism/AsterismTests/AppLibraryModelTests.swift Modified +49 / -2
diff --git a/Asterism/AsterismTests/AppLibraryModelTests.swift b/Asterism/AsterismTests/AppLibraryModelTests.swiftindex ba37bc4..5ed461c 100644--- a/Asterism/AsterismTests/AppLibraryModelTests.swift+++ b/Asterism/AsterismTests/AppLibraryModelTests.swift@@ -273,7 +273,8 @@ struct AppLibraryModelTests { struct AppLibraryModelDiagnosisRefreshTests {      /// The expected call sequence of one `refreshDiagnosesAndSnapshots`.-    private static let refreshSequence = ["refreshDiagnostics", "recentPresentation", "works"]+    private static let refreshSequence =+        ["refreshDiagnostics", "recentPresentation", "works", "siteNames"]      @Test("Activation re-derives the diagnoses before it rebuilds the snapshots (Req 1.5)")     func activationRefreshesDiagnosesFirst() async {@@ -365,7 +366,7 @@ struct AppLibraryModelSyncArrivalTests {     /// The call sequence one arrival must produce: reconcile first, then the     /// existing diagnosis-before-snapshots refresh.     private static let arrivalSequence =-        ["reconcileAfterSync", "refreshDiagnostics", "recentPresentation", "works"]+        ["reconcileAfterSync", "refreshDiagnostics", "recentPresentation", "works", "siteNames"]      private static func mirroringConfiguration() -> LibraryConfiguration {         LibraryConfiguration(@@ -934,6 +935,52 @@ struct AppLibraryModelSnapshotPublicationTests {         #expect(model.recentGroups.count == 1)         #expect(model.snapshotGeneration == 1)     }++    /// `site-display-names` Q2: the lookup every screen names a site by is read+    /// beside the snapshots and published under the same guard, so the names a+    /// reader sees belong to the cycle that drew the rows.+    @Test("The site name lookup is published with the cycle that read it")+    func siteNamesPublishWithTheCycle() async {+        let mock = MockLibraryProvider()+        mock.siteNamesResult = .success(["composed.test": "Composed Reader"])+        let model = AppLibraryModel(readyRepository: mock)++        #expect(model.siteNames == .empty)++        await model.refreshAll()++        #expect(mock.siteNamesCallCount == 1)+        #expect(model.siteNames.label(for: "composed.test") == "Composed Reader")++        // A cycle that cannot complete leaves the previous names standing: a+        // fresher name beside the previous rows is a pair that never existed.+        mock.siteNamesResult = .success(["composed.test": "Renamed"])+        mock.worksResult = .failure(+            MockLibraryProvider.MockError.simulatedFailure("works read failed"))++        await model.refreshAll()++        #expect(model.siteNames.label(for: "composed.test") == "Composed Reader")+        #expect(model.snapshotGeneration == 1)+    }++    @Test("A refresh whose siteNames() read throws leaves the previous cycle standing")+    func aThrowingSiteNamesReadPublishesNothing() async {+        let mock = MockLibraryProvider()+        let firstWorks = Self.seededWorks(title: "Published Work")+        mock.worksResult = .success(firstWorks)+        let model = AppLibraryModel(readyRepository: mock)+        await model.refreshAll()++        mock.siteNamesResult = .failure(+            MockLibraryProvider.MockError.simulatedFailure("site names read failed"))+        mock.worksResult = .success(Self.seededWorks(title: "Later Work"))++        await model.refreshAll()++        #expect(model.worksSnapshot == firstWorks)+        #expect(model.snapshotGeneration == 1)+    } }  // MARK: - Preserved captures: drain invocation and the report's lifecycle
Asterism/AsterismTests/Helpers/MockLibraryProvider.swift Modified +18 / -0
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftindex 78272fa..c40d84f 100644--- a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -157,6 +157,7 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {     // MARK: - M5 reads and work deletion      var sitesCallCount = 0+    var siteNamesCallCount = 0     var workDetailCallCount = 0     var entryExportInputCallCount = 0     var workExportInputCallCount = 0@@ -164,6 +165,7 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {     var commitWorkDeletionCallCount = 0      var sitesResult: Result<[SiteSnapshot], Error> = .success([])+    var siteNamesResult: Result<[String: String], 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)@@ -187,6 +189,22 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {         return try sitesResult.get()     } +    func siteNames() async throws -> [String: String] {+        siteNamesCallCount += 1+        callLog.append("siteNames")+        return try siteNamesResult.get()+    }++    /// The last `(hostname, name)` the screen asked for, and what it was told.+    var lastRenamedSite: (hostname: String, name: String)?+    var renameSiteResult: Result<SiteRenameOutcome, Error> = .success(.renamed)++    func renameSite(hostname: String, to name: String) async throws -> SiteRenameOutcome {+        callLog.append("renameSite")+        lastRenamedSite = (hostname, name)+        return try renameSiteResult.get()+    }+     /// Every `(workID, hostname)` pair the screen asked to drop, in order.     var removedSiteMemberships: [(workID: UUID, hostname: String)] = []     var removeSiteMembershipResult: Result<Void, Error> = .success(())
Asterism/AsterismTests/PendingCaptureWatcherTests.swift Modified +2 / -1
diff --git a/Asterism/AsterismTests/PendingCaptureWatcherTests.swift b/Asterism/AsterismTests/PendingCaptureWatcherTests.swiftindex d658f0c..69b1133 100644--- a/Asterism/AsterismTests/PendingCaptureWatcherTests.swift+++ b/Asterism/AsterismTests/PendingCaptureWatcherTests.swift@@ -224,7 +224,8 @@ struct AppLibraryModelQueueArrivalTests {     /// The same sequence `AppLibraryModelDiagnosisRefreshTests` pins for an     /// activation: the diagnoses are re-derived before the snapshots are     /// rebuilt, because Recent is built from them.-    private static let refreshSequence = ["refreshDiagnostics", "recentPresentation", "works"]+    private static let refreshSequence =+        ["refreshDiagnostics", "recentPresentation", "works", "siteNames"]      @Test("An arrival drains and then republishes, so the row reaches Recent (Req 4.6)")     func arrivalRefreshesAfterTheDrain() async {
Asterism/AsterismTests/SiteNamesTests.swift Added +85 / -0
diff --git a/Asterism/AsterismTests/SiteNamesTests.swift b/Asterism/AsterismTests/SiteNamesTests.swiftnew file mode 100644index 0000000..82a68ef--- /dev/null+++ b/Asterism/AsterismTests/SiteNamesTests.swift@@ -0,0 +1,85 @@+import SwiftUI+import Testing+@testable import Asterism++/// The app-wide site name lookup (`site-display-names` Q2, Q3, Q5): what a+/// hostname is called, and what a surface outside the Sites list spells it.+@Suite("Site names lookup")+struct SiteNamesTests {++    // MARK: - The name++    @Test("A hostname the lookup knows reads as its display name")+    func knownHostnameReadsAsItsName() {+        let names = SiteNames(["composed.test": "Composed Reader"])+        #expect(names.name(for: "composed.test") == "Composed Reader")+        #expect(names.label(for: "composed.test") == "Composed Reader")+    }++    /// The lookup is a snapshot of the sites the last refresh saw. A hostname+    /// that arrived after it — or one the read simply does not carry — must+    /// still name itself rather than come out blank.+    @Test("A hostname the lookup has never heard of reads as itself")+    func unknownHostnameFallsBackToTheHostname() {+        let names = SiteNames(["composed.test": "Composed Reader"])+        #expect(names.name(for: "other.test") == "other.test")+        #expect(names.label(for: "other.test") == "other.test")+    }++    /// An unnamed site's resolved name *is* its hostname, so nothing about this+    /// surface changes for a library that has never renamed anything.+    @Test("An unnamed site reads as its hostname")+    func unnamedSiteReadsAsItsHostname() {+        let names = SiteNames(["plain.test": "plain.test"])+        #expect(names.name(for: "plain.test") == "plain.test")+        #expect(names.label(for: "plain.test") == "plain.test")+    }++    @Test("The empty lookup names every hostname by itself")+    func emptyLookupNamesEveryHostnameByItself() {+        #expect(SiteNames.empty.name(for: "any.test") == "any.test")+        #expect(SiteNames.empty.label(for: "any.test") == "any.test")+    }++    /// Req: the default environment value is the empty lookup, so a+    /// presentation path that never receives one shows exactly the hostnames it+    /// showed before this feature.+    @Test("An unset environment value yields hostnames")+    @MainActor func unsetEnvironmentYieldsHostnames() {+        let unset = EnvironmentValues().siteNames+        #expect(unset == .empty)+        #expect(unset.label(for: "example.test") == "example.test")+    }++    // MARK: - The disambiguation (Q5)++    /// Names are not unique and nothing enforces that they should be, so the+    /// two rows that would read alike each say which site they are.+    @Test("Two sites sharing a name both wear their hostname")+    func sharedNameAppendsTheHostname() {+        let names = SiteNames(["a.test": "Reader", "b.test": "Reader", "c.test": "Other"])+        #expect(names.name(for: "a.test") == "Reader")+        #expect(names.label(for: "a.test") == "Reader (a.test)")+        #expect(names.label(for: "b.test") == "Reader (b.test)")+        // Untouched: nothing else resolves to "Other".+        #expect(names.label(for: "c.test") == "Other")+    }++    /// A site renamed to another site's *hostname* collides too — and the site+    /// that owns that hostname keeps reading as itself rather than as+    /// `b.test (b.test)`, which is already the spelling that says which it is.+    @Test("A site named after another site's hostname leaves that site's own label alone")+    func aNameThatIsAnotherHostnameLeavesThatSiteAlone() {+        let names = SiteNames(["a.test": "b.test", "b.test": "b.test"])+        #expect(names.label(for: "a.test") == "b.test (a.test)")+        #expect(names.label(for: "b.test") == "b.test")+    }++    @Test("Three sites sharing a name are each spelled apart")+    func threeSitesSharingANameAreEachSpelledApart() {+        let names = SiteNames(["a.test": "Reader", "b.test": "Reader", "c.test": "Reader"])+        let labels = ["a.test", "b.test", "c.test"].map(names.label(for:))+        #expect(Set(labels).count == 3)+        #expect(labels == ["Reader (a.test)", "Reader (b.test)", "Reader (c.test)"])+    }+}
Asterism/AsterismTests/SitesModelTests.swift Modified +139 / -5
diff --git a/Asterism/AsterismTests/SitesModelTests.swift b/Asterism/AsterismTests/SitesModelTests.swiftindex fee8c24..f1c54b7 100644--- a/Asterism/AsterismTests/SitesModelTests.swift+++ b/Asterism/AsterismTests/SitesModelTests.swift@@ -79,9 +79,9 @@ struct SitesListModelTests { struct SiteDetailModelTests {      private func snapshot(-        hostname: String = "royalroad.com", mode: SiteMode+        hostname: String = "royalroad.com", displayName: String = "Royal Road", mode: SiteMode     ) -> SiteSnapshot {-        SiteSnapshot.fixture(hostname: hostname, displayName: "Royal Road", mode: mode)+        SiteSnapshot.fixture(hostname: hostname, displayName: displayName, mode: mode)     }      /// Built through the planner teach mode uses, not hand-assembled: the count@@ -102,19 +102,153 @@ struct SiteDetailModelTests {      @MainActor private func makeSUT(         mode: SiteMode = .taught,+        displayName: String = "Royal Road",         canReteach: Bool = true     ) -> (SiteDetailModel, MockLibraryProvider, ReteachRecorder) {         let mock = MockLibraryProvider()         let recorder = ReteachRecorder()         let model = SiteDetailModel(-            site: snapshot(mode: mode),+            site: snapshot(displayName: displayName, mode: mode),             library: mock,             canReteach: canReteach,             onReteach: { hostname, permits in recorder.record(hostname, permits) },-            onMutation: {})+            onMutation: {},+            onChanged: {})         return (model, mock, recorder)     } +    // MARK: - Rename++    @Test("The field opens on the stored name and renaming is a no-op until it changes")+    @MainActor func canRenameOnlyOnAChange() {+        let (model, _, _) = makeSUT(mode: .taught)++        #expect(model.draftName == "Royal Road")+        #expect(model.currentName == "Royal Road")+        #expect(!model.canRename)++        // Trimming happens before the comparison, so surrounding whitespace is+        // still the same name.+        model.draftName = "  Royal Road  "+        #expect(!model.canRename)++        model.draftName = "Royal Road Fiction"+        #expect(model.canRename)+    }++    /// Q13: no case folding, so a case-only change is a rename the reader meant.+    @Test("A case-only change is a rename")+    @MainActor func caseOnlyChangeIsARename() {+        let (model, _, _) = makeSUT(mode: .taught)++        model.draftName = "royal road"++        #expect(model.canRename)+    }++    @Test("A successful rename passes the reader's text on and follows the stored name")+    @MainActor func renameFollowsTheStoredName() async {+        let mock = MockLibraryProvider()+        let mutations = MutationRecorder()+        let changes = MutationRecorder()+        let model = SiteDetailModel(+            site: snapshot(mode: .taught), library: mock, canReteach: true,+            onReteach: { _, _ in }, onMutation: { mutations.record() },+            onChanged: { changes.record() })+        model.draftName = "  Royal Road Fiction  "++        await model.rename()++        #expect(mock.lastRenamedSite?.hostname == "royalroad.com")+        #expect(mock.lastRenamedSite?.name == "  Royal Road Fiction  ")+        #expect(model.currentName == "Royal Road Fiction")+        // The field shows what was stored, not what was typed.+        #expect(model.draftName == "Royal Road Fiction")+        #expect(model.errorMessage == nil)+        // Both hosts are told: the list behind the screen, and the snapshots+        // every other surface reads its names from.+        #expect(mutations.count == 1)+        #expect(changes.count == 1)+    }++    /// Q4: an empty field is the reset, not a refusal — the site goes back to+    /// its hostname and the title follows.+    @Test("Emptying the field resets the site to its hostname")+    @MainActor func emptyResetsToHostname() async {+        let (model, mock, _) = makeSUT(mode: .taught)+        model.draftName = "   "++        #expect(model.canRename)+        await model.rename()++        #expect(mock.lastRenamedSite?.name == "   ")+        #expect(model.currentName == "royalroad.com")+        #expect(model.draftName == "royalroad.com")+        #expect(model.errorMessage == nil)+    }++    @Test("Emptying the field on a site already under its hostname writes nothing")+    @MainActor func emptyOnAnUnnamedSiteIsANoOp() async {+        let (model, mock, _) = makeSUT(mode: .taught, displayName: "royalroad.com")+        model.draftName = ""++        #expect(!model.canRename)+        await model.rename()++        #expect(mock.lastRenamedSite?.hostname == nil)+    }++    @Test("A refused name is shown as a sentence and the name does not move")+    @MainActor func rejectionIsASentence() async {+        let (model, mock, _) = makeSUT(mode: .taught)+        mock.renameSiteResult = .success(.rejected(.containsLineBreaksOrControlCharacters))+        model.draftName = "Royal\nRoad"++        await model.rename()++        #expect(model.errorMessage != nil)+        #expect(model.errorMessage?.hasSuffix(".") == true)+        #expect(model.errorMessage?.contains("line breaks") == true)+        #expect(model.currentName == "Royal Road")+    }++    @Test("A failed write is reported and the name does not move")+    @MainActor func renameFailureIsReported() async {+        let (model, mock, _) = makeSUT(mode: .taught)+        mock.renameSiteResult = .failure(+            MockLibraryProvider.MockError.simulatedFailure("nope"))+        model.draftName = "Royal Road Fiction"++        await model.rename()++        #expect(model.errorMessage != nil)+        #expect(model.currentName == "Royal Road")+    }++    // MARK: - The site's own page (Q9)++    @Test("A hostname's link is its front page over https")+    @MainActor func siteLinkIsTheFrontPage() {+        let (model, _, _) = makeSUT(mode: .taught)++        #expect(model.siteURL?.absoluteString == "https://royalroad.com/")+    }++    /// A stored hostname that will not parse contributes no row rather than an+    /// inert one — the `WorkDetailModel.siteLinks` rule.+    @Test("A hostname that cannot be part of a URL yields no link")+    @MainActor func unbuildableHostnameYieldsNoLink() {+        // Pinned first, so the test is about the model and not about a guess at+        // what `URL(string:)` rejects.+        #expect(URL(string: "https://has space.test/") == nil)++        let (model, _, _) = makeSUT(mode: .taught)+        let unbuildable = SiteDetailModel.siteURL(hostname: "has space.test")++        #expect(unbuildable == nil)+        #expect(model.siteURL != nil)+    }+     // MARK: - Re-teach (Req 6.2, 6.3)      @Test("Re-teaching a taught site routes without the articles-conversion flag")@@ -184,7 +318,7 @@ struct SiteDetailModelTests {         let mutations = MutationRecorder()         let model = SiteDetailModel(             site: snapshot(mode: .taught), library: mock, canReteach: true,-            onReteach: { _, _ in }, onMutation: { mutations.record() })+            onReteach: { _, _ in }, onMutation: { mutations.record() }, onChanged: {})         let contract = articlesContract(entryCount: 2)         mock.projectArticlesResult = .success(contract)         mock.commitArticlesResult = .success(.committed)
Asterism/AsterismTests/WorkMergeModelTests.swift Modified +3 / -2
diff --git a/Asterism/AsterismTests/WorkMergeModelTests.swift b/Asterism/AsterismTests/WorkMergeModelTests.swiftindex e4e5301..efbea5f 100644--- a/Asterism/AsterismTests/WorkMergeModelTests.swift+++ b/Asterism/AsterismTests/WorkMergeModelTests.swift@@ -467,7 +467,7 @@ struct MergeDestinationPickerTests {                 assignment: .configured(UUID()), name: "Webtoon", kind: .active),             entries: [TestFixtures.makeEntry(hostname: "a.example")]) -        let label = WorkMergeView.destinationLabel(work, unavailable: nil)+        let label = WorkMergeView.destinationLabel(work, unavailable: nil, siteNames: .empty)          #expect(label.contains("Zephyr"))         #expect(label.contains("a.example"))@@ -475,7 +475,8 @@ struct MergeDestinationPickerTests {         #expect(label.contains("Webtoon"))         #expect(label.contains("1 note")) -        let refused = WorkMergeView.destinationLabel(work, unavailable: "Needs attention.")+        let refused = WorkMergeView.destinationLabel(+            work, unavailable: "Needs attention.", siteNames: .empty)         #expect(refused.contains("Needs attention."))     } 
Asterism/AsterismTests/WorksFilterPresentationTests.swift Modified +36 / -8
diff --git a/Asterism/AsterismTests/WorksFilterPresentationTests.swift b/Asterism/AsterismTests/WorksFilterPresentationTests.swiftindex 0f8275a..8ede1d4 100644--- a/Asterism/AsterismTests/WorksFilterPresentationTests.swift+++ b/Asterism/AsterismTests/WorksFilterPresentationTests.swift@@ -34,29 +34,56 @@ struct WorksFilterPresentationTests {         let options = WorksFilterOptions(works: [             Self.work(typeDisplay: Self.typed("Manga"), tags: ["shonen"], hostnames: ["a.example"]),             Self.work(typeDisplay: Self.typed("manga"), tags: ["shonen"], hostnames: ["a.example"]),-        ])+        ], siteNames: .empty)         let filter = WorksFilter(             type: .named(WorkTypeName.normalize("MANGA")), tag: "shonen", hostname: "a.example")          #expect(-            WorksFilterPresentation.activeLabels(filter, options: options)+            WorksFilterPresentation.activeLabels(filter, options: options, siteNames: .empty)                 == ["Manga", "shonen", "a.example"])     } +    /// `site-display-names`: the pill is the reader reading back the row they+    /// picked, so it wears the same spelling the menu row does — including the+    /// hostname where another site shares the name (Q5). The filter itself is+    /// still keyed by hostname.+    @Test("The site pill wears the site's display label")+    @MainActor func theSitePillWearsTheDisplayLabel() {+        let names = SiteNames(["a.example": "Alpha", "b.example": "Beta"])+        let options = WorksFilterOptions(+            works: [Self.work(hostnames: ["a.example", "b.example"])], siteNames: names)++        #expect(+            WorksFilterPresentation.activeLabels(+                WorksFilter(hostname: "a.example"), options: options, siteNames: names)+                == ["Alpha"])++        let shared = SiteNames(["a.example": "Alpha", "b.example": "Alpha"])+        #expect(+            WorksFilterPresentation.activeLabels(+                WorksFilter(hostname: "a.example"), options: options, siteNames: shared)+                == ["Alpha (a.example)"])+    }+     @Test("A dimension nobody chose draws no pill")     @MainActor func unchosenDimensionsDrawNoPill() {-        let options = WorksFilterOptions(works: [Self.work(tags: ["shonen"])])+        let options = WorksFilterOptions(works: [Self.work(tags: ["shonen"])], siteNames: .empty)         #expect(-            WorksFilterPresentation.activeLabels(WorksFilter(tag: "shonen"), options: options)+            WorksFilterPresentation.activeLabels(+                WorksFilter(tag: "shonen"), options: options, siteNames: .empty)                 == ["shonen"])-        #expect(WorksFilterPresentation.activeLabels(WorksFilter(), options: options).isEmpty)+        #expect(+            WorksFilterPresentation.activeLabels(+                WorksFilter(), options: options, siteNames: .empty+            ).isEmpty)     }      @Test("The untyped filter's pill reads Untyped")     @MainActor func untypedPillReadsUntyped() {-        let options = WorksFilterOptions(works: [Self.work()])+        let options = WorksFilterOptions(works: [Self.work()], siteNames: .empty)         #expect(-            WorksFilterPresentation.activeLabels(WorksFilter(type: .untyped), options: options)+            WorksFilterPresentation.activeLabels(+                WorksFilter(type: .untyped), options: options, siteNames: .empty)                 == [WorksFilterOptions.untypedLabel])     } @@ -74,7 +101,8 @@ struct WorksFilterPresentationTests {      @Test("An offered selection is named by its option, not by its normalised name")     func offeredSelectionKeepsItsSpelling() {-        let options = WorksFilterOptions(works: [Self.work(typeDisplay: Self.typed("Manga"))])+        let options = WorksFilterOptions(+            works: [Self.work(typeDisplay: Self.typed("Manga"))], siteNames: .empty)         #expect(options.name(for: .named(WorkTypeName.normalize("Manga"))) == "Manga")     } 
Asterism/AsterismTests/WorksListOptionsTests.swift Modified +58 / -13
diff --git a/Asterism/AsterismTests/WorksListOptionsTests.swift b/Asterism/AsterismTests/WorksListOptionsTests.swiftindex 88398d8..182e549 100644--- a/Asterism/AsterismTests/WorksListOptionsTests.swift+++ b/Asterism/AsterismTests/WorksListOptionsTests.swift@@ -267,7 +267,7 @@ struct WorksFilterTests {         let options = WorksFilterOptions(works: [             work(1, typeDisplay: typed("Manga", kind: .active), tags: ["shonen"],                 hostnames: ["a.example"])-        ])+        ], siteNames: .empty)         let filter = WorksFilter(             type: .named(WorkTypeName.normalize("Manga")), tag: "seinen", hostname: "a.example")         let pruned = filter.pruned(to: options)@@ -284,7 +284,8 @@ struct WorksFilterTests {      @Test("A filter the options still cover is returned unchanged")     func pruningKeepsCoveredFilter() {-        let options = WorksFilterOptions(works: [work(1, tags: ["t"], hostnames: ["h"])])+        let options = WorksFilterOptions(+            works: [work(1, tags: ["t"], hostnames: ["h"])], siteNames: .empty)         let filter = WorksFilter(type: .untyped, tag: "t", hostname: "h")         #expect(filter.pruned(to: options) == filter)     }@@ -312,7 +313,7 @@ struct WorksFilterOptionsTests {         let options = WorksFilterOptions(works: [             work(1, typeDisplay: typed("Manga", kind: .active)),             work(2, typeDisplay: typed("manga", kind: .active)),-        ])+        ], siteNames: .empty)         #expect(options.types.map(\.name) == ["Manga"])         #expect(options.types.map(\.selection) == [.named(WorkTypeName.normalize("manga"))])     }@@ -321,13 +322,13 @@ struct WorksFilterOptionsTests {     func untypedOptionOnlyWhenPresent() {         let typedOnly = WorksFilterOptions(works: [             work(1, typeDisplay: typed("Manga", kind: .active))-        ])+        ], siteNames: .empty)         #expect(!typedOnly.types.contains { $0.selection == .untyped })          let withUnresolved = WorksFilterOptions(works: [             work(1, typeDisplay: typed("Manga", kind: .active)),             work(2, typeDisplay: unresolvedType),-        ])+        ], siteNames: .empty)         #expect(withUnresolved.types.contains { $0.selection == .untyped })         #expect(             withUnresolved.types.first { $0.selection == .untyped }?.name@@ -339,19 +340,20 @@ struct WorksFilterOptionsTests {         let allRemoved = WorksFilterOptions(works: [             work(1, typeDisplay: typed("Manhwa", kind: .removed)),             work(2, typeDisplay: typed("manhwa", kind: .removed)),-        ])+        ], siteNames: .empty)         #expect(allRemoved.types.map(\.isDimmed) == [true])          let oneActive = WorksFilterOptions(works: [             work(1, typeDisplay: typed("Manhwa", kind: .removed)),             work(2, typeDisplay: typed("Manhwa", kind: .active)),-        ])+        ], siteNames: .empty)         #expect(oneActive.types.map(\.isDimmed) == [false])     }      @Test("The Untyped option is never dimmed")     func untypedIsNeverDimmed() {-        let options = WorksFilterOptions(works: [work(1), work(2, typeDisplay: unresolvedType)])+        let options = WorksFilterOptions(+            works: [work(1), work(2, typeDisplay: unresolvedType)], siteNames: .empty)         #expect(options.types.map(\.isDimmed) == [false])     } @@ -361,7 +363,7 @@ struct WorksFilterOptionsTests {             work(1, typeDisplay: typed("Novel", kind: .active)),             work(2, typeDisplay: typed("Manga", kind: .active)),             work(3, typeDisplay: typed("anthology", kind: .active)),-        ])+        ], siteNames: .empty)         #expect(options.types.map(\.name) == ["anthology", "Manga", "Novel"])     } @@ -372,7 +374,7 @@ struct WorksFilterOptionsTests {         let options = WorksFilterOptions(works: [             work(1, tags: ["shonen", "action"]),             work(2, tags: ["Shonen", "action"]),-        ])+        ], siteNames: .empty)         // Deduplicated case-sensitively, so the two spellings are two options.         // Which of the two `localizedStandardCompare` puts first is its own         // business; that "action" leads is the ordering this asserts.@@ -386,13 +388,56 @@ struct WorksFilterOptionsTests {         let options = WorksFilterOptions(works: [             work(1, hostnames: ["z.example", "a.example"]),             work(2, hostnames: ["a.example"]),-        ])+        ], siteNames: .empty)         #expect(options.hostnames == ["a.example", "z.example"])     } +    /// `site-display-names` Req 11: the menu is read top to bottom, so it is+    /// ordered by what the rows say — which is no longer the hostname once a+    /// site has been renamed. The values stay hostnames: they are the filter's+    /// key, and the identifiers and tags are built from them (Q10).+    @Test("Site options are ordered by their labels, keyed by hostname")+    func siteOptionsAreOrderedByLabel() {+        let names = SiteNames(["z.example": "Alpha", "a.example": "Zenith"])+        let options = WorksFilterOptions(works: [+            work(1, hostnames: ["z.example", "a.example"])+        ], siteNames: names)++        #expect(options.hostnames == ["z.example", "a.example"])+    }++    /// Two sites sharing a name sort by the label they actually wear, which+    /// carries the hostname — so the pair is ordered by hostname among+    /// themselves and stays put relative to everything else.+    @Test("Sites sharing a name order by the disambiguated label")+    func sitesSharingANameOrderByTheirDisambiguatedLabel() {+        let names = SiteNames([+            "z.example": "Alpha", "a.example": "Alpha", "m.example": "Beta",+        ])+        let options = WorksFilterOptions(works: [+            work(1, hostnames: ["m.example", "z.example", "a.example"])+        ], siteNames: names)++        // "Alpha (a.example)", "Alpha (z.example)", "Beta".+        #expect(options.hostnames == ["a.example", "z.example", "m.example"])+    }++    /// An unnamed site's label is its hostname, so a library that has renamed+    /// nothing keeps exactly the order it had.+    @Test("An unnamed library orders sites exactly as it did before")+    func anUnnamedLibraryKeepsItsOrder() {+        let hostnames = ["z.example", "a.example", "m.example"]+        let works = [work(1, hostnames: hostnames)]+        let named = SiteNames(Dictionary(uniqueKeysWithValues: hostnames.map { ($0, $0) }))++        #expect(+            WorksFilterOptions(works: works, siteNames: named).hostnames+                == WorksFilterOptions(works: works, siteNames: .empty).hostnames)+    }+     @Test("An empty snapshot offers no options")     func emptySnapshotOffersNothing() {-        let options = WorksFilterOptions(works: [])+        let options = WorksFilterOptions(works: [], siteNames: .empty)         #expect(options.types.isEmpty)         #expect(options.tags.isEmpty)         #expect(options.hostnames.isEmpty)@@ -409,7 +454,7 @@ struct WorksFilterOptionsTests {             work(2, typeDisplay: typed("Novel", kind: .active), tags: ["seinen"],                 hostnames: ["b.example"]),         ]-        let all = WorksFilterOptions(works: works)+        let all = WorksFilterOptions(works: works, siteNames: .empty)         let filtered = WorksFilter(hostname: "a.example").apply(to: works)         #expect(filtered.count == 1)         // The vocabularies the pickers offer are the full snapshot's, so a
Asterism/AsterismTests/WorksRowPresentationTests.swift Modified +20 / -3
diff --git a/Asterism/AsterismTests/WorksRowPresentationTests.swift b/Asterism/AsterismTests/WorksRowPresentationTests.swiftindex 029a70a..b92c746 100644--- a/Asterism/AsterismTests/WorksRowPresentationTests.swift+++ b/Asterism/AsterismTests/WorksRowPresentationTests.swift@@ -15,25 +15,42 @@ struct WorksRowPresentationTests {     @MainActor func theLabelNamesEverySite() {         let single = TestFixtures.makeWork(displayTitle: "Zephyr", hostname: "a.example")         #expect(-            WorksRowPresentation.openLabel(for: single)+            WorksRowPresentation.openLabel(for: single, siteNames: .empty)                 == "Open Work Zephyr from a.example")          let merged = TestFixtures.makeWork(             displayTitle: "Zephyr",             memberships: TestFixtures.makeMemberships(["a.example", "b.example"]))-        let label = WorksRowPresentation.openLabel(for: merged)+        let label = WorksRowPresentation.openLabel(for: merged, siteNames: .empty)         #expect(label.contains("a.example"))         #expect(label.contains("b.example"))         // Membership order, which is Req 1.2's — not whichever the set iterated.         #expect(label == "Open Work Zephyr from a.example, b.example")     } +    /// `site-display-names` Q16: VoiceOver hears what the merge picker's row+    /// shows, which is the site's display label rather than its hostname.+    @Test("The label names the sites by their display labels")+    @MainActor func theLabelNamesSitesByTheirDisplayLabels() {+        let work = TestFixtures.makeWork(+            displayTitle: "Zephyr",+            memberships: TestFixtures.makeMemberships(["a.example", "b.example"]))+        let names = SiteNames(["a.example": "Alpha", "b.example": "Alpha"])++        // Both renamed to "Alpha", so both wear their hostname (Q5) and the+        // sentence still tells the two sites apart.+        #expect(+            WorksRowPresentation.openLabel(for: work, siteNames: names)+                == "Open Work Zephyr from Alpha (a.example), Alpha (b.example)")+    }+     /// Req 8.1's tolerated state. "from " with nothing after it is what a     /// joined empty list would read as.     @Test("A membership-less Work is named without a site rather than with an empty one")     @MainActor func aMembershiplessWorkIsNamedWithoutASite() {         let work = TestFixtures.makeWork(displayTitle: "Orphan", memberships: [])-        #expect(WorksRowPresentation.openLabel(for: work) == "Open Work Orphan")+        #expect(+            WorksRowPresentation.openLabel(for: work, siteNames: .empty) == "Open Work Orphan")     }      // MARK: - The dismiss pill (Req 5.5)
Asterism/AsterismUITests/SitesSettingsUITests.swift Modified +112 / -4
diff --git a/Asterism/AsterismUITests/SitesSettingsUITests.swift b/Asterism/AsterismUITests/SitesSettingsUITests.swiftindex c8bc204..0980d7e 100644--- a/Asterism/AsterismUITests/SitesSettingsUITests.swift+++ b/Asterism/AsterismUITests/SitesSettingsUITests.swift@@ -2,12 +2,15 @@ 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+/// Three 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.+/// opens only after Settings has actually gone (`pendingReteachHostname`). The+/// articles switch is a confirmation over a projection, which is the shape that+/// has silently done nothing before. And `site-display-names`' rename has to+/// reach surfaces this screen does not draw — the list behind it and the work+/// page outside Settings entirely, both of which read the name through the+/// environment rather than from the screen that wrote it. /// /// `seeded-composed` carries both site modes the screen distinguishes: an /// untaught `composed.test` and a taught `id.test`.@@ -46,6 +49,21 @@ final class SitesSettingsUITests: XCTestCase {         waitFor(app.anyElement("site-detail-view"), "The site's screen opens")     } +    /// Types a new name over the stored one and commits it.+    ///+    /// The field is cleared with deletes rather than a selection, which is what+    /// `WorkTypesSettingsUITests` does for the same field shape — a tap lands+    /// past the end of a short name in a full-width field, so the cursor is at+    /// the end and the deletes eat the whole of it.+    private func renameSite(from current: String, to name: String) {+        let field = waitFor(+            app.textFields["site-detail-name-field"], "The screen opens on the stored name")+        field.tap()+        field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: current.count + 2))+        field.typeText(name)+        waitFor(app.buttons["site-detail-rename-button"], "The screen offers Rename").tap()+    }+     // MARK: - The list (Req 6.1)      func testSettingsRoutesToASitesListCarryingEverySite() {@@ -109,4 +127,94 @@ final class SitesSettingsUITests: XCTestCase {         XCTAssertFalse(             app.anyElement("site-detail-error").exists, "The switch reported no failure")     }++    // MARK: - Rename (`site-display-names`)++    /// The rename, and the two places it has to land: the list the reader comes+    /// straight back to, and a screen outside Settings altogether.+    ///+    /// It renames **both** seeded sites, because the fixture puts the two halves+    /// on different hosts. `composed.test` is the untaught capture — the site+    /// whose screen this asserts the link row on — and it holds no Work at all;+    /// the fixture's one Work ("Real Work") is on `id.test`. One rename could+    /// prove the Sites list or the work page, not both.+    ///+    /// The work page is the half that matters most: `SiteNames` reaches every+    /// non-Settings surface through the environment, so a rename that stopped at+    /// the screen that wrote it would still pass every assertion above.+    func testARenamedSiteCarriesItsNameToTheListAndOffSettings() {+        openSitesList()+        openSiteDetail("composed.test")++        // Q9: the link is labelled by the hostname, which is the identity and+        // not the name that is about to change.+        waitFor(+            app.anyElement("site-detail-link-composed.test"),+            "The site's screen offers the link to the site")++        renameSite(from: "composed.test", to: "Composed Stories")++        // A rename leaves the reader here, so the screen has to follow it.+        XCTAssertTrue(+            app.navigationBars.staticTexts.matching(+                NSPredicate(format: "label CONTAINS[c] 'Composed Stories'")).firstMatch+                .waitForExistence(timeout: 15),+            "The title names the site the reader just renamed")+        XCTAssertEqual(+            app.textFields["site-detail-name-field"].value as? String, "Composed Stories",+            "The field shows what was stored, not what was typed")+        XCTAssertFalse(+            app.anyElement("site-detail-error").exists, "An accepted name is not explained away")+        // Q10: the identity did not move — the link row is still keyed and+        // labelled by the hostname.+        XCTAssertTrue(+            app.anyElement("site-detail-link-composed.test").exists,+            "A renamed site still links to its own hostname")++        app.goBack()+        waitFor(app.anyElement("settings-sites-list"), "Back lands on the Sites list")+        // The list reloads on the way back rather than on the next launch.+        XCTAssertTrue(+            app.buttons.matching(+                NSPredicate(+                    format: "identifier == 'site-row-composed.test' "+                        + "AND label CONTAINS 'Composed Stories'")+            ).firstMatch.waitForExistence(timeout: 15),+            "The row reads as the new name, under its unchanged hostname-keyed identifier")+        XCTAssertTrue(+            app.staticTexts.matching(+                NSPredicate(format: "label BEGINSWITH 'composed.test'")).firstMatch.exists,+            "…over the hostname, which is still what says which site it is")++        // The second half, on the host the fixture's Work is actually on.+        openSiteDetail("id.test")+        renameSite(from: "id.test", to: "Identity Press")+        XCTAssertTrue(+            app.navigationBars.staticTexts.matching(+                NSPredicate(format: "label CONTAINS[c] 'Identity Press'")).firstMatch+                .waitForExistence(timeout: 15),+            "The second rename took as well")++        app.goBack()+        waitFor(app.anyElement("settings-sites-list"), "Back lands on the Sites list again")+        app.goBack()+        waitFor(app.anyElement("settings-view"), "…and out to Settings itself")+        waitFor(app.buttons["settings-done-button"], "Settings closes from its own bar").tap()+        waitUntilGone(app.anyElement("settings-view"), "Settings is gone")++        selectTab(.works, in: app)+        waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+        waitFor(+            app.elements(withIdentifierPrefix: "work-row-").firstMatch,+            "The seeded work is listed"+        ).tap()+        waitFor(app.anyElement("work-detail-pulse"), "The work's page opens")+        XCTAssertTrue(+            app.descendants(matching: .any).matching(+                NSPredicate(+                    format: "identifier == 'work-detail-site-id.test' "+                        + "AND label CONTAINS 'Identity Press'")+            ).firstMatch.waitForExistence(timeout: 15),+            "The work page's site row shows the name, without a relaunch")+    } }
Asterism/AsterismUITests/UIJourneySupport.swift Modified +9 / -0
diff --git a/Asterism/AsterismUITests/UIJourneySupport.swift b/Asterism/AsterismUITests/UIJourneySupport.swiftindex c7a5c8d..58395d1 100644--- a/Asterism/AsterismUITests/UIJourneySupport.swift+++ b/Asterism/AsterismUITests/UIJourneySupport.swift@@ -61,6 +61,15 @@ extension XCUIApplication {         if sidebarRow.exists { return sidebarRow }         return barButton     }++    /// The way back off a pushed screen.+    ///+    /// The bar's first button, by position rather than by label: a back+    /// control's label is the previous screen's title, and naming it would be+    /// asserting on that screen's name (`WorkDetailActionsUITests`' reasoning).+    func goBack() {+        navigationBars.buttons.element(boundBy: 0).tap()+    } }  extension XCTestCase {
CHANGELOG.md Modified +96 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d258e4a..757b378 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,102 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Changed +- **Site display names are verified end to end (site-display-names,+  phase "Verification", T-2303 — spec complete).** Every surface on the+  smolspec's exhaustive list was re-checked against the code and reads+  the lookup, while identifiers, tags, filter keys, glyph colour and the+  destructive confirmations stay hostname-keyed; the table is in+  `specs/site-display-names/implementation.md`. The pass found one new+  compiler warning — `WorksFilterPresentation.activeLabels` is+  `nonisolated` and called a main-actor `label(for:)` under the project's+  default actor isolation — fixed by declaring `SiteNames` `nonisolated`+  (Q23), a pure Sendable value. `make test-core` (2,252 tests) and+  `make test-quick` (1,125 tests, after a clean `build-mac`) exit 0, and+  the full `make test-ui` re-run on the final tree is green except the+  three pre-existing `M4ScaleRecentPerformanceUITests` seed-timeout+  cases. No new warnings in an unwrapped iOS or Mac build. The pre-push+  review then moved every site-name rule into `SiteName` (shared with+  import's "unnamed" predicate, Q25), added+  `SiteResolutionOrder.orderedByHostname`, gave the Mac `Settings` scene+  its own `\.siteNames` write, and pinned the export site line for a+  loser-only name; `implementation.md` carries the three-level+  explanation.++- **A site can be renamed from its own screen and links to itself+  (site-display-names, phase "Sites screen", T-2303).** `SiteDetailView`+  opens with a Name section in the `WorkTypeDetailView` shape — a field+  prefilled with the current name and a Rename button, secondary-styled+  because the re-teach already holds this screen's one gradient primary+  (Q21) — under the hostname header, which stays the identity.+  `SiteDetailModel` owns the draft, `canRename` (off for a no-op, a+  whitespace-only edit and an empty field on a site already under its+  hostname; a case-only change is a rename, Q13), `rename()`, the one+  rejection sentence, and a `currentName` the navigation title follows.+  Empty input resets the site to its hostname (Q4) and the field then+  shows what was stored. A successful rename calls `onMutation`, which+  refreshes the snapshots and the `siteNames` lookup, and a new+  `onChanged` closure threaded through `SitesListView`, `SettingsView`,+  `SettingsScreen` and `AppLibraryModel.siteDetailModel` that reloads the+  list behind the screen, so nothing needs a relaunch. A `Link` row opens+  `https://<hostname>/`, labelled with the hostname (Q9) under+  `site-detail-link-<hostname>`, and is omitted where no URL can be+  built. `SitesModelTests` cover every arm plus URL construction for a+  normal and an unbuildable hostname. A `SitesSettingsUITests` journey on+  `seeded-composed` renames both seeded sites (Q22): the link row exists,+  the title follows, the list row shows the name over the hostname under+  its unchanged hostname identifier, and a work's detail site row on the+  renamed site reads the new name off Settings. Full `make test-ui`+  green (the three pre-existing `M4ScaleRecentPerformanceUITests` cases+  excepted), `make test-quick` green, no new warnings.++- **Every reader-facing surface names a site by its display name+  (site-display-names, phase "App: lookup and surfaces", T-2303).** A+  `SiteNames` lookup (`ViewModels/SiteNames.swift`) wraps the+  `siteNames()` read: `name(for:)` falls back to the hostname, and+  `label(for:)` appends the hostname where another site in the library+  resolves to the same name (Q5), the shared set computed once at+  construction. `AppLibraryModel.refreshAll` reads it beside the+  snapshots and publishes it under the same generation guard (Q2), and+  `ContentView` hands it to every screen as the app's first custom+  environment value, `\.siteNames` (Q3), set once at the top of the+  window because both trees push entry detail from outside the screens+  `AppScreens` builds (Q18). `SiteLabel` reads it, so the work detail+  site row, works rows and merge preview rows follow unchanged; the Work+  URL site picker and identity review menu, the works site filter menu+  (keys still hostnames, sorted by label with a hostname tiebreak) and+  its active pill, Stats "Most read sites", entry detail's capture+  details (name with the hostname beneath where they differ, Q20) and+  Recent's row accessibility label all read the lookup; two more+  accessibility sentences moved with them (Q19). Identifiers, picker+  tags, filter keys, glyph colour and the destructive confirmations stay+  hostname-keyed (Q10, Q12). Unit tests cover the fallback, the empty+  environment, two- and three-site disambiguation, the publication+  cycle, label order and the site pill. `make test-quick` and+  `make test-core` green, no new warnings.++- **A site's display name has one rule and one write+  (site-display-names, phase "Core: name rule, rename write, name read",+  T-2303).** `LibraryRepository.siteDisplayName` now reads every Site+  row for a hostname in `SiteResolutionOrder` and returns the first+  custom name (non-empty, not the hostname), else the hostname (Q6); the+  Sites list, the markdown export site line and the archive projection+  of a consolidated site all go through it, so a duplicate row minted+  unnamed by another device, or a winner that flips after a teach, can+  no longer hide a name. `SiteLookupCache` caches the whole ordered row+  set per hostname. `LibraryProviding` gains `renameSite(hostname:to:)`+  returning `SiteRenameOutcome` — trimmed, empty resets to the hostname+  (Q4), line breaks or control characters rejected through the new+  `SiteName` validator that shares `WorkTypeName.forbidden` (Q15), every+  row for the hostname written (Q7), an exact-match no-op saves nothing+  (Q13), an unknown hostname throws `invalidInput` (Q14) — and a+  `siteNames()` read returning every hostname's resolved name from one+  Site fetch for the app-layer lookup (Q2). `MockLibraryProvider` stubs+  both. Core tests cover the three name-rule arms on the Sites read and+  the archive value, every rename arm (the no-op through an injected+  save strategy) and the `siteNames()` shape; `BackupGoldenExportTests`+  bytes are unchanged. `make test-core` and `make test-quick` green with+  no new warnings.+ - **The Works list's sort and filters are proven from launch   (works-list-options, phase "Fixture and journeys", T-2302 — spec   complete).** A `seeded-works-options` scenario seeds four works over
Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swift Modified +12 / -5
diff --git a/Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swiftindex b31cdef..040f2cf 100644--- a/Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/IdentityResolution.swift@@ -56,11 +56,18 @@ public enum SiteResolutionOrder {     /// — the `sitesByHost[hostname] = site` last-write-wins map the V4 pass once     /// built is exactly what Q16 removed.     public static func winnersByHostname(_ sites: [Site]) -> [String: Site] {-        var winners: [String: Site] = [:]-        for (hostname, rows) in Dictionary(grouping: sites, by: \.hostname) {-            winners[hostname] = sorted(rows).first-        }-        return winners+        orderedByHostname(sites).compactMapValues(\.first)+    }++    /// Every hostname present in `sites` mapped to its rows in resolution+    /// order, winner first.+    ///+    /// What a read needs when it wants more than the winner — the name rule+    /// reads every row of a hostname (Q6 of `specs/site-display-names`) — and+    /// the one place the grouping and the ordering are composed, so the winner+    /// this returns first and `winnersByHostname`'s cannot differ.+    public static func orderedByHostname(_ sites: [Site]) -> [String: [Site]] {+        Dictionary(grouping: sites, by: \.hostname).mapValues(sorted)     }      /// The comparator behind `sorted`, exposed for the order-algebra tests.
Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift Modified +19 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swiftindex 877946f..c798ab8 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift@@ -19,6 +19,15 @@ public protocol LibraryProviding: Sendable {     /// appearing twice (Q24) — row-level duplication is Library Check's subject.     func sites() async throws -> [SiteSnapshot] +    /// Every stored hostname mapped to the name it presents under, for the+    /// app-wide lookup every surface that names a site reads.+    ///+    /// The same rule `sites()` names a row by (Q6), from the same one Site+    /// fetch — but deliberately not `sites()` itself, which also enumerates the+    /// membership table for its work counts. This is read on every refresh and+    /// has no use for them.+    func siteNames() async throws -> [String: String]+     /// Drops one of a Work's site memberships (Req 7.2).     ///     /// On the seam because the work detail's edit mode offers it: the reader@@ -29,6 +38,16 @@ public protocol LibraryProviding: Sendable {     /// where neither is true, and the repository is what enforces it.     func removeSiteMembership(workID: UUID, hostname: String) async throws +    /// Sets a site's display name from its own settings screen.+    ///+    /// Trimmed; empty after trimming resets the site to its hostname (Q4); a+    /// name carrying a line break or a control character is rejected with a+    /// reason the screen turns into a sentence. Every Site row for the hostname+    /// is written (Q7), and a rename to the name they already hold writes+    /// nothing. A hostname with no stored row is refused with+    /// `invalidInput(operation: "renameSite")` (Q14).+    func renameSite(hostname: String, to name: String) async throws -> SiteRenameOutcome+     /// One row per hostname for the rule-suggestion sweep and its invalidation     /// pass (Reqs 1.6, 5.1, 5.5, Q37): Site mode, live rule versions, capture     /// count and newest capture. `nil` reads every hostname; a set reads only
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift Modified +4 / -4
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swiftindex 4b6e553..f1d913d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift@@ -456,10 +456,10 @@ extension LibraryRepository {         else {             return         }-        // An unnamed row takes the archive's name. "Unnamed" is empty *or* the-        // hostname itself: the constructor's default, and the only value any-        // path but an import has ever written.-        if site.displayName.isEmpty || site.displayName == site.hostname,+        // An unnamed row takes the archive's name, tested through the name+        // rule's own predicate: one definition of "custom name" for the rename's+        // reset, the name rule and import precedence (Q8, Q25).+        if !SiteName.isCustom(site.displayName, hostname: site.hostname),            site.displayName != record.displayName {             site.displayName = record.displayName         }
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift Modified +20 / -10
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swiftindex 1316197..76d00d8 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift@@ -78,21 +78,30 @@ extension LibraryRepository {      // MARK: - Shared derivation -    /// The Site row each hostname presents through, resolved once per read.+    /// The Site rows 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.+    /// A per-hostname 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. The whole ordered+    /// set is cached because title cleaning reads the winner while the name rule+    /// reads every row (Q6).     internal struct SiteLookupCache {-        private var rows: [String: Site?] = [:]+        private var cache: [String: [Site]] = [:]++        /// Every row for the hostname, winner first.+        mutating func rows(+            for hostname: String, context: ModelContext+        ) throws -> [Site] {+            if let cached = cache[hostname] { return cached }+            let fetched = try LibraryRepository.fetchSites(hostname: hostname, context: context)+            cache[hostname] = fetched+            return fetched+        }          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+            try rows(for: hostname, context: context).first         }     } @@ -174,7 +183,8 @@ extension LibraryRepository {     private static func siteDisplayName(         hostname: String, sites: inout SiteLookupCache, context: ModelContext     ) throws -> String {-        siteDisplayName(try sites.site(for: hostname, context: context), hostname: hostname)+        SiteName.displayName(+            of: try sites.rows(for: hostname, context: context), hostname: hostname)     }      /// The date line's text (Req 1.3, Q8): `firstCapturedAt` in the given
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift Modified +78 / -21
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swiftindex 209f1df..eef8ff2 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift@@ -15,11 +15,14 @@ extension LibraryRepository {             let rows = try context.fetch(FetchDescriptor<Site>())             let counts = try Self.workCountsByHostname(context: context)             // 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($0, workCount: counts[$0.hostname] ?? 0) }+            // (`SiteResolutionOrder`), so this screen describes the teaching of+            // the row a teach from it would write to. The *name* comes from+            // every row of the hostname instead (Q6).+            return SiteResolutionOrder.orderedByHostname(rows)+                .compactMap { hostname, ordered in+                    Self.siteSnapshot(+                        ordered, hostname: hostname, workCount: counts[hostname] ?? 0)+                }                 .sorted { left, right in                     let byName = left.displayName.localizedStandardCompare(right.displayName)                     if byName != .orderedSame { return byName == .orderedAscending }@@ -28,20 +31,49 @@ extension LibraryRepository {         }     } +    /// Every stored hostname mapped to the name it presents under, for the+    /// app-wide lookup (Q2).+    ///+    /// One Site fetch, grouped by hostname, through the same rule the Sites list+    /// names a row by (Q6) — so the two cannot answer differently for the same+    /// hostname. Deliberately not built on `sites()`: that read also enumerates+    /// the membership table for its work counts, and this one runs on every+    /// refresh with no use for them.+    ///+    /// A hostname with rows this app cannot describe still gets a name: unlike+    /// the Sites list, which omits such a row rather than offering mode-dependent+    /// actions for a state it does not understand, naming it costs nothing and+    /// leaving it out would show a hostname where every other surface shows a+    /// name.+    public func siteNames() async throws -> [String: String] {+        try await withLockedContext(mode: .shared, operation: "reading site names") { context in+            let rows = try context.fetch(FetchDescriptor<Site>())+            var names: [String: String] = [:]+            for (hostname, ordered) in SiteResolutionOrder.orderedByHostname(rows) {+                names[hostname] = SiteName.displayName(of: ordered, hostname: hostname)+            }+            return names+        }+    }+     /// A site row as the screen sees it, or nil where this app cannot describe-    /// it.+    /// it. `ordered` is the hostname's rows in `SiteResolutionOrder`, winner+    /// first: the winner supplies the teaching, all of them supply the name.     ///     /// 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, workCount: Int) -> SiteSnapshot? {-        guard let mode = SiteMode(rawValue: site.modeRaw) else { return nil }-        let displayName = siteDisplayName(site, hostname: site.hostname)+    private static func siteSnapshot(+        _ ordered: [Site], hostname: String, workCount: Int+    ) -> SiteSnapshot? {+        guard let site = ordered.first, let mode = SiteMode(rawValue: site.modeRaw) else {+            return nil+        }         return SiteSnapshot(-            hostname: site.hostname,-            displayName: displayName,+            hostname: hostname,+            displayName: SiteName.displayName(of: ordered, hostname: hostname),             mode: mode,             // Oldest rule first.             patternIDs: site.patternValues.sorted(by: RuleSelection.precedes).map(\.id),@@ -156,16 +188,41 @@ extension LibraryRepository {         }     } -    /// The name a Site presents under: its declared display name, or the-    /// hostname where that is blank or absent.+    /// Sets the name a site presents under, from its own settings screen.+    ///+    /// **Every row for the hostname is written** (Q7), not the winner alone: the+    /// winner is content-dependent (`SiteResolutionOrder`), so a teach on a+    /// duplicated hostname would otherwise revert the name to whatever the other+    /// row carries. Writing all of them converges the store without giving the+    /// reconciler a write to a synced column.     ///-    /// 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+    /// An empty name after trimming is the **reset** (Q4): the hostname is what+    /// "no custom name" has always meant here, in the name rule and in import.+    /// A rename to the name every row already holds succeeds and writes nothing+    /// — no save, and so no sync churn for a no-op. The comparison is exact+    /// (Q13): with no uniqueness rule there is nothing to fold against, and a+    /// case-only change is a rename the reader meant.+    public func renameSite(hostname: String, to name: String) async throws -> SiteRenameOutcome {+        try await withLockedContext(mode: .exclusive, operation: "renaming a site") { context in+            if let rejection = SiteName.validate(name) { return .rejected(rejection) }+            let resolved = SiteName.stored(name, hostname: hostname)+            let rows = try context.fetch(+                FetchDescriptor<Site>(predicate: #Predicate { $0.hostname == hostname }))+            // Q14: `invalidInput`, not `recordNotFound` — that case is UUID-keyed+            // and a Site has none. Unreachable from the Sites screen, which lists+            // stored rows and never deletes one.+            guard !rows.isEmpty else {+                throw LibraryRepositoryError.invalidInput(+                    operation: "renameSite",+                    reason: "No site is stored for \(hostname).")+            }+            guard rows.contains(where: { $0.displayName != resolved }) else { return .renamed }+            for row in rows { row.displayName = resolved }+            do { try self.saveStrategy.save(context) } catch {+                throw LibraryRepositoryError.libraryUnavailable(+                    operation: "renaming a site", reason: String(describing: error))+            }+            return .renamed+        }     } }
Packages/AsterismCore/Sources/AsterismCore/SiteName.swift Added +90 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/SiteName.swift b/Packages/AsterismCore/Sources/AsterismCore/SiteName.swiftnew file mode 100644index 0000000..9ceffc1--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/SiteName.swift@@ -0,0 +1,90 @@+import Foundation++/// Why a site display name is not a legal name. A reason, not a sentence: the+/// settings model builds the wording, so the repository never carries+/// reader-facing text.+///+/// One case, and there will not be a second by accident. A name that is empty+/// after trimming is **not** a rejection: it resets the site to its hostname+/// (Q4), which is what "no custom name" has always meant in the store, in+/// import and in the name rule.+public enum SiteNameRejection: Equatable, Sendable {+    /// A line break or a control character: every surface that renders a site+    /// name is one line, and a name carrying one would render differently on+    /// each of them.+    case containsLineBreaksOrControlCharacters+}++/// What a rename did. There is no `unchanged`: a rename to the name the site+/// already shows is a success the reader cannot tell apart from any other, and+/// the screen has nothing different to say about it.+public enum SiteRenameOutcome: Equatable, Sendable {+    case renamed+    case rejected(SiteNameRejection)+}++/// The name rule for a site's display name (Q15).+///+/// Deliberately *not* `WorkTypeName`: that validator answers `.empty` first,+/// which is this feature's reset case, and its error carries a case sites cannot+/// produce. Only the forbidden character set is shared, so the two agree about+/// what a single-line name is.+///+/// There is no normalization here, and that is the whole of Q13. Work-type names+/// fold case and composition because uniqueness is compared across them; site+/// names have no uniqueness rule (Q5), so there is nothing to compare against+/// and a case-only change is a rename the reader meant.+public enum SiteName {++    /// The stored spelling: the reader's text with surrounding whitespace+    /// removed and nothing else touched.+    public static func trimmed(_ raw: String) -> String {+        raw.trimmingCharacters(in: .whitespacesAndNewlines)+    }++    /// `nil` when the name is legal, which an empty name is.+    public static func validate(_ raw: String) -> SiteNameRejection? {+        let name = trimmed(raw)+        guard !name.unicodeScalars.contains(where: WorkTypeName.forbidden.contains) else {+            return .containsLineBreaksOrControlCharacters+        }+        return nil+    }++    /// What a rename to `raw` stores: the trimmed name, or the hostname where+    /// the reader emptied the field, because an empty name is the reset (Q4)+    /// and not a refusal.+    ///+    /// One definition, so the screen predicts exactly what the repository+    /// writes.+    public static func stored(_ raw: String, hostname: String) -> String {+        let name = trimmed(raw)+        return name.isEmpty ? hostname : name+    }++    /// Whether a stored name is a name the reader set: non-blank, and not the+    /// hostname the site already presents under. `Site.init` fills the column+    /// with the hostname, so "equals the hostname" is what unnamed has always+    /// meant here — in the store, in import and in the rename's reset (Q4).+    static func isCustom(_ name: String, hostname: String) -> Bool {+        !trimmed(name).isEmpty && name != hostname+    }++    /// The name a hostname presents under (Q6): the first **custom** name among+    /// its rows in `SiteResolutionOrder` — one that is neither blank nor the+    /// hostname itself — and the hostname where no row carries one.+    ///+    /// Every row, not the winner alone. The winner is content-dependent by+    /// design, so a teach on either row of a duplicated hostname flips it: a+    /// winner-only read would silently revert a name the reader set, and a row+    /// another device minted unnamed would hide it.+    ///+    /// The one rule for the Sites list, the app-wide name lookup, markdown+    /// export and the archive projection of a consolidated site, so a site the+    /// list calls by one name and the export by another cannot exist.+    ///+    /// `ordered` is the hostname's rows in resolution order, winner first.+    static func displayName(of ordered: [Site], hostname: String) -> String {+        ordered.first { isCustom($0.displayName, hostname: hostname) }?.displayName ?? hostname+    }+}
Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swift Modified +5 / -6
diff --git a/Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swiftindex 76309e8..6e0464c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/SiteUnionProjection.swift@@ -253,7 +253,11 @@ enum SiteUnionProjection {          return ProjectedSite(             hostname: hostname,-            displayName: displayName(survivor: survivor, hostname: hostname),+            // The one name rule (Q6), over every row rather than the survivor+            // alone: a name the reader set on a row that later loses the order+            // still reaches the archive, and the value here cannot drift from+            // what the Sites list and the export show for the same hostname.+            displayName: SiteName.displayName(of: ordered, hostname: hostname),             mode: mode(                 survivor: survivor, keptActive: keptActive, keptCurrent: keptCurrent,                 patterns: unionPatterns, rules: unionRules),@@ -267,11 +271,6 @@ enum SiteUnionProjection {      // MARK: - Survivor teaching state -    private static func displayName(survivor: Site?, hostname: String) -> String {-        guard let survivor, !survivor.displayName.isEmpty else { return hostname }-        return survivor.displayName-    }-     /// The union's mode.     ///     /// A row holding an active title rule is `.taught` by the validator's closed
Packages/AsterismCore/Sources/AsterismCore/WorkTypeName.swift Modified +6 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkTypeName.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeName.swiftindex 12ae1c8..a292dce 100644--- a/Packages/AsterismCore/Sources/AsterismCore/WorkTypeName.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeName.swift@@ -64,5 +64,10 @@ public enum WorkTypeName {     /// Control characters and every line separator. `.newlines` is unioned in     /// deliberately: U+2028 and U+2029 are separators rather than controls, and     /// a name carrying one is not the single-line name the list displays.-    private static let forbidden = CharacterSet.controlCharacters.union(.newlines)+    ///+    /// Internal rather than private because `SiteName` shares it (Q15 of+    /// `site-display-names`): the two validators answer different questions+    /// about emptiness and uniqueness, but "single-line" is one rule and this is+    /// where it is spelled.+    static let forbidden = CharacterSet.controlCharacters.union(.newlines) }
Packages/AsterismCore/Tests/AsterismCoreTests/ExportInputReadTests.swift Modified +18 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ExportInputReadTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ExportInputReadTests.swiftindex 8b68bb2..aa56016 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ExportInputReadTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ExportInputReadTests.swift@@ -244,6 +244,24 @@ struct ExportInputReadTests {                 .sites.map(\.name) == ["orphan.test"])     } +    @Test("The site line takes a name only a losing row carries (Q6)")+    func siteNameFromALosingRow() async throws {+        let fixture = try await M5Fixture()+        let workID = UUID()+        try await fixture.repository.seedM5Rows(+            sites: [+                M5SeedSite(hostname: "dup.test", displayName: "Reader Name"),+                // The taught row wins `SiteResolutionOrder` and carries no+                // custom name, so a winner-only read would export the hostname.+                M5SeedSite(hostname: "dup.test", mode: .taught, wholeTitleRule: true),+            ],+            works: [M5SeedWork(id: workID, displayTitle: "Named By The Loser", hostname: "dup.test")]+        )+        #expect(+            try await fixture.repository.workExportInput(workID: workID, locale: auLocale)+                .sites.map(\.name) == ["Reader Name"])+    }+     @Test("The locale parameter reaches the date text (1.3, Q8)")     func localeReachesDateText() async throws {         let fixture = try await M5Fixture()
Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift Modified +18 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swiftindex 3414008..5c5649f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift@@ -577,6 +577,19 @@ extension LibraryRepository {         }     } +    /// Every stored Site row's display name for one hostname, sorted so two+    /// reads compare directly. The winner-only read below cannot check a rename:+    /// Q7 writes *all* the rows, and the loser is exactly the one a winner-only+    /// assertion would miss.+    func m5SiteNames(hostname: String) async throws -> [String] {+        try await withLockedContext(mode: .shared, operation: "reading Site names") { context in+            try context+                .fetch(FetchDescriptor<Site>(predicate: #Predicate { $0.hostname == hostname }))+                .map(\.displayName)+                .sorted()+        }+    }+     /// 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.@@ -596,13 +609,16 @@ final class M5Fixture {     let directory: URL     let repository: LibraryRepository -    init(clock: any RepositoryClock = FixedRepositoryClock(M5Fixture.epoch)) async throws {+    init(+        clock: any RepositoryClock = FixedRepositoryClock(M5Fixture.epoch),+        saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()+    ) async throws {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismM5Tests-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)         repository = try await LibraryRepository.openForApp(             LibraryConfiguration(rootDirectory: directory), clock: clock,-            saveStrategy: ModelContextSaveStrategy()).repository+            saveStrategy: saveStrategy).repository     }      deinit { try? FileManager.default.removeItem(at: directory) }
Packages/AsterismCore/Tests/AsterismCoreTests/SiteRenameTests.swift Added +140 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteRenameTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteRenameTests.swiftnew file mode 100644index 0000000..6ff3373--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteRenameTests.swift@@ -0,0 +1,140 @@+import Foundation+import Testing++@testable import AsterismCore++// The one write `site-display-names` adds: the reader names a site from its own+// settings screen.+//+// Two rules carry most of this. **An empty name is not a refusal** (Q4) — it is+// the reset to the hostname, which is what "no custom name" has always meant in+// the store — and **every row for the hostname is written** (Q7), because the+// winner among duplicate rows is content-dependent and a winner-only write would+// let a later teach revert the name.+@Suite("Site rename", .serialized)+struct SiteRenameTests {++    @Test("A name is stored trimmed")+    func nameIsTrimmed() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(sites: [M5SeedSite(hostname: "composed.test")])++        #expect(+            try await fixture.repository.renameSite(hostname: "composed.test", to: "  Composed  ")+                == .renamed)+        #expect(try await fixture.repository.m5SiteNames(hostname: "composed.test") == ["Composed"])+    }++    /// Q13: no folding, so the site the reader capitalised is renamed rather+    /// than left alone.+    @Test("A case-only change is a rename")+    func caseOnlyChangeIsARename() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(sites: [+            M5SeedSite(hostname: "composed.test", displayName: "composed weekly")+        ])++        #expect(+            try await fixture.repository.renameSite(hostname: "composed.test", to: "Composed Weekly")+                == .renamed)+        #expect(+            try await fixture.repository.m5SiteNames(hostname: "composed.test")+                == ["Composed Weekly"])+    }++    /// Q4: the reset, not a rejection. A distinct "clear" control would be a+    /// second way to reach the same state.+    @Test(+        "A name that is empty after trimming resets the site to its hostname",+        arguments: ["", "   ", "\t "])+    func emptyResetsToTheHostname(name: String) async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(sites: [+            M5SeedSite(hostname: "composed.test", displayName: "Composed Weekly")+        ])++        #expect(try await fixture.repository.renameSite(hostname: "composed.test", to: name) == .renamed)+        #expect(+            try await fixture.repository.m5SiteNames(hostname: "composed.test")+                == ["composed.test"])+    }++    @Test(+        "A line break or a control character is refused with the reason, and writes nothing",+        arguments: ["Composed\nWeekly", "Composed\u{2028}Weekly", "Composed\u{0007}Weekly"])+    func controlCharactersAreRefused(name: String) async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(sites: [+            M5SeedSite(hostname: "composed.test", displayName: "Composed Weekly")+        ])++        #expect(+            try await fixture.repository.renameSite(hostname: "composed.test", to: name)+                == .rejected(.containsLineBreaksOrControlCharacters))+        #expect(+            try await fixture.repository.m5SiteNames(hostname: "composed.test")+                == ["Composed Weekly"])+    }++    /// The no-op reports success and leaves the context with nothing pending, so+    /// CloudKit carries no round trip for a rename that changed nothing. The+    /// save strategy is the observation point: `Site` has no modification+    /// timestamp, so comparing the stored names could not tell a rewrite of the+    /// same value apart from no write at all.+    @Test("A rename to the name every row already holds succeeds and saves nothing")+    func noOpRenameWritesNothing() async throws {+        let saves = InstrumentedSaveStrategy()+        let fixture = try await M5Fixture(saveStrategy: saves)+        try await fixture.repository.seedM5Rows(sites: [+            M5SeedSite(hostname: "composed.test", displayName: "Composed Weekly"),+            M5SeedSite(hostname: "composed.test", displayName: "Composed Weekly"),+        ])+        saves.resetCounts()++        // Trimmed first, so the stored spelling with padding is still a no-op.+        #expect(+            try await fixture.repository.renameSite(+                hostname: "composed.test", to: " Composed Weekly ") == .renamed)++        #expect(saves.attemptCount == 0)+        #expect(+            try await fixture.repository.m5SiteNames(hostname: "composed.test")+                == ["Composed Weekly", "Composed Weekly"])+    }++    /// A row already carrying the new name is not what makes the rename a no-op:+    /// its twin does not, so both are written.+    @Test("Every Site row for a duplicated hostname is written (Q7)")+    func everyRowForTheHostnameIsWritten() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(sites: [+            M5SeedSite(hostname: "dup.test", displayName: "Named By One Device"),+            M5SeedSite(hostname: "dup.test"),+            M5SeedSite(hostname: "other.test", displayName: "Untouched"),+        ])++        #expect(try await fixture.repository.renameSite(hostname: "dup.test", to: "Both") == .renamed)++        #expect(try await fixture.repository.m5SiteNames(hostname: "dup.test") == ["Both", "Both"])+        #expect(try await fixture.repository.m5SiteNames(hostname: "other.test") == ["Untouched"])+    }++    /// Q14: `invalidInput` naming the operation, not `recordNotFound`, which is+    /// UUID-keyed and a Site has no UUID.+    @Test("A hostname with no stored Site row is refused as invalid input")+    func unknownHostnameIsRefused() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(sites: [M5SeedSite(hostname: "composed.test")])++        do {+            _ = try await fixture.repository.renameSite(hostname: "absent.test", to: "Absent")+            Issue.record("expected a refusal")+        } catch let error as LibraryRepositoryError {+            guard case .invalidInput(let operation, _) = error else {+                Issue.record("expected invalidInput, got \(error)")+                return+            }+            #expect(operation == "renameSite")+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift Modified +30 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swiftindex 7ed4248..030f2d4 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift@@ -108,6 +108,36 @@ struct SiteUnionProjectionTests {         #expect(projected.patterns.count(where: \.isActive) == 1)     } +    // MARK: - Q6: the archived name follows the name rule, not the survivor++    @Test("A name only a losing row carries reaches the archived Site")+    func loserOnlyNameReachesTheArchive() throws {+        let store = try ProjectionStore()+        let rows = try store.makeRows([+            SiteUnionRowSpec(), SiteUnionRowSpec(patterns: [(version: 1, active: true)], mode: .taught),+        ])+        // `makeRows` names every row; the reader's name has to sit on the row+        // the order strips for this to say anything.+        rows[0].displayName = "Reader Name"+        rows[1].displayName = ProjectionStore.hostname++        let projected = SiteUnionProjection.project(hostname: ProjectionStore.hostname, rows: rows)++        #expect(projected.survivor === rows[1])+        #expect(projected.displayName == "Reader Name")+    }++    @Test("A hostname whose rows carry no custom name archives as its hostname")+    func unnamedRowsArchiveAsTheHostname() throws {+        let store = try ProjectionStore()+        let rows = try store.makeRows([SiteUnionRowSpec(), SiteUnionRowSpec()])+        for row in rows { row.displayName = ProjectionStore.hostname }++        let projected = SiteUnionProjection.project(hostname: ProjectionStore.hostname, rows: rows)++        #expect(projected.displayName == ProjectionStore.hostname)+    }+     // MARK: - Q40: rowless hostnames synthesise an untaught wire Site      @Test("A hostname with records but no row projects to a synthesised untaught Site")
Packages/AsterismCore/Tests/AsterismCoreTests/SitesListReadTests.swift Modified +82 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SitesListReadTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SitesListReadTests.swiftindex 76b277e..fd2bd40 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SitesListReadTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SitesListReadTests.swift@@ -55,6 +55,88 @@ struct SitesListReadTests {         #expect(duplicated.displayName == winners["dup.test"])     } +    // MARK: - The name rule (Q6)++    // A hostname resolves to the first *custom* name among its rows in+    // `SiteResolutionOrder`. The taught row wins that order on step 1, so these+    // three cases are the whole rule: the winner named, only a loser named, and+    // neither.++    @Test("A named winner names the hostname")+    func namedWinnerNamesTheHostname() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(sites: [+            M5SeedSite(hostname: "dup.test"),+            M5SeedSite(+                hostname: "dup.test", displayName: "Winner Name", mode: .taught,+                wholeTitleRule: true),+        ])++        let winners = try await fixture.repository.m5SiteWinnerNames()+        #expect(winners["dup.test"] == "Winner Name")+        let sites = try await fixture.repository.sites()+        #expect(sites.map(\.displayName) == ["Winner Name"])+    }++    @Test("A name only a losing row carries still names the hostname")+    func loserNameNamesTheHostname() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(sites: [+            M5SeedSite(hostname: "dup.test", displayName: "Reader Name"),+            // The taught row wins the order and carries no custom name — the+            // shape another device's capture mints, and the shape a teach on a+            // duplicated hostname produces after a rename.+            M5SeedSite(hostname: "dup.test", mode: .taught, wholeTitleRule: true),+        ])++        let winners = try await fixture.repository.m5SiteWinnerNames()+        #expect(winners["dup.test"] == "dup.test")+        let sites = try await fixture.repository.sites()+        #expect(sites.map(\.displayName) == ["Reader Name"])+    }++    @Test("A hostname no row names presents as its hostname")+    func unnamedHostnamePresentsAsItself() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(sites: [+            M5SeedSite(hostname: "dup.test"),+            M5SeedSite(hostname: "dup.test", mode: .taught, wholeTitleRule: true),+        ])++        let sites = try await fixture.repository.sites()+        #expect(sites.map(\.displayName) == ["dup.test"])+    }++    // MARK: - The app-wide name lookup++    @Test("Every hostname reads back under the name rule (Q2, Q6)")+    func siteNamesResolvesEveryHostname() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(sites: [+            M5SeedSite(hostname: "winner.test"),+            M5SeedSite(+                hostname: "winner.test", displayName: "Winner Name", mode: .taught,+                wholeTitleRule: true),+            M5SeedSite(hostname: "loser.test", displayName: "Loser Name"),+            M5SeedSite(hostname: "loser.test", mode: .taught, wholeTitleRule: true),+            M5SeedSite(hostname: "unnamed.test"),+        ])++        let names = try await fixture.repository.siteNames()+        #expect(+            names == [+                "winner.test": "Winner Name",+                "loser.test": "Loser Name",+                "unnamed.test": "unnamed.test",+            ])+    }++    @Test("An empty library names no hostname")+    func siteNamesOnAnEmptyLibrary() async throws {+        let fixture = try await M5Fixture()+        #expect(try await fixture.repository.siteNames().isEmpty)+    }+     @Test("Rows sort by display name, with the hostname breaking ties")     func sortedByDisplayName() async throws {         let fixture = try await M5Fixture()
specs/OVERVIEW.md Modified +12 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 4ad05ec..5bc812e 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -36,6 +36,7 @@ | [Stats Period Navigation](#stats-period-navigation) | 2026-08-30 | Done — all 7 tasks implemented 2026-08-31 across three phases (derivation, view, documentation); `make test-quick`, `AsterismUITests/StatsUITests` and `AsterismUITests/AccessibilityJourneyUITests` green with no new warnings. Q16's `Menu` fallback was **not** needed: the capsule passes the no-clipping assertion at `AccessibilityExtraExtraExtraLarge` (Q26) | Smolspec (T-2216). Replaces the Stats page's five-period `Menu` with a Week / Month / All time toggle, back and forward chevrons and a date picker, so any week or month in the library's history is reachable directly and clamped at both ends; an All-time bar switches to that month in place of the pushed month screen. Adds two top-five ranked lists for the shown period — most-read works and most-read sites (capture hostnames), counted on first capture. App-layer only; supersedes in part `stats-page` Reqs 3.1, 3.2, 5.2–5.5, Req 2.10's All-time exclusion, its site and "five named periods" non-goals, Decision 3's drill-down half, Q30, Q31 and Q55, and rewrites design §5.4. | | [iPad and Mac Layouts](#ipad-and-mac-layouts) | 2026-08-28 | Done — all 34 tasks implemented 2026-09-01 across five phases and four review-fix rounds; `make verify-identity`, `make test-quick` (with the Mac build and appex), `make test-ui-ipad` (12/12) and the iPhone journeys green (pre-existing M4Scale sim trio excepted). Remaining the owner's: the 46-row manual Mac/iPad checklist in `verification-run.md` (the Mac sky is still visually unverified), and four open questions — Req 1.7's pane-wide pushes (F4), the detail column's missing title (A10/F6), Req 6.1's wording (C4, Q49), and Req 9.4 vs `AdaptiveColorTests` (G1). T-2298 filed for the pre-existing phone Stats accessibility breach the new suite exposed | T-2286. Gives the iPad and the Mac a layout of their own — a sidebar with the three tabs beside list and detail columns, collapsing to the phone layout as the window narrows — and brings the app and a share extension to the Mac as a native SwiftUI build against the same CloudKit-mirrored library. Navigation state moves into one `AppNavigation` object owned by the App; two files hold every platform conditional; a spool directory watcher and a visibility-based lifecycle replace the phone's activation semantics on the Mac. Design canvas in `docs/ipad-and-mac/`. | | [Works List Options](#works-list-options) | 2026-09-03 | Done — all 10 tasks implemented 2026-09-03 across three phases (pure logic, the list, fixture and journeys); `make test-quick` and `make test-ui` green (pre-existing M4Scale sim trio excepted) with no new warnings. The Mac toolbar menu's rendering remains the owner's manual check | Smolspec (T-2302). A four-way sort for the Works list (Newest first, Oldest first, A to Z, Z to A) and one-value filters by type, tag and site, from one toolbar menu on iPhone, iPad and Mac. The sort persists on the device; filters are view state with the search query's lifetime. Empty works keep their trailing section under the date sorts and join one section under the title sorts. App-layer only, on `WorkSnapshot`; amends `polish-and-export` Req 4.1's fixed-ordering clause. |+| [Site Display Names](#site-display-names) | 2026-09-04 | Done — all 9 tasks implemented and verified 2026-09-04; `make test-core`, `make test-quick` and `make test-ui` green (the three pre-existing M4Scale sim cases excepted) with no new warnings | Smolspec (T-2303). The reader can rename a site from its Settings screen, and the display name replaces the hostname on every surface that names a site — Sites screens, work detail row and pickers, works rows and their site filter, merge preview, Stats most-read sites, entry detail, Recent's accessibility label — with the hostname appended wherever two sites share a name. The site screen also links to `https://<hostname>/`. Uses the existing `Site.displayName` column: no schema, archive-format or sync-attribute change. One name rule for a hostname with several rows (first custom name in resolution order) shared by the Sites read, export and the archive projection; the rename writes every row for the hostname; names reach the views through an app-layer lookup published as a SwiftUI environment value, never via core snapshots. |  --- @@ -606,3 +607,14 @@ Smolspec (T-2302). The Works list gains a sort choice and three filters from one - [tasks.md](works-list-options/tasks.md) - [decision_log.md](works-list-options/decision_log.md) - [implementation.md](works-list-options/implementation.md)++## Site Display Names++**Created:** 2026-09-04 · **Status:** Done++Smolspec (T-2303). Adds the one write `Site.displayName` never had — a rename on the site's own Settings screen, trimmed, empty resetting to the hostname, line breaks refused, no uniqueness — and shows the name in place of the hostname on every reader-facing surface, with the hostname appended wherever another site shares the name (Q5) and kept verbatim in destructive confirmations (Q12). A hostname with several Site rows is named by one rule — the first custom name in resolution order, else the hostname — implemented once and used by the Sites read, markdown export and the archive projection (Q6), and the rename writes every row for the hostname so the reconciler gains no write (Q7). Names reach the views through a `SiteNames` lookup read once per `refreshAll` and injected as the app's first custom SwiftUI environment value (Q2, Q3); no snapshot type or budgeted read changes. The site screen also links to `https://<hostname>/` (Q9). No schema, archive-format or sync-attribute change; import precedence unchanged (Q8).++- [smolspec.md](site-display-names/smolspec.md)+- [tasks.md](site-display-names/tasks.md)+- [decision_log.md](site-display-names/decision_log.md)+- [implementation.md](site-display-names/implementation.md)
specs/site-display-names/decision_log.md Added +31 / -0
diff --git a/specs/site-display-names/decision_log.md b/specs/site-display-names/decision_log.mdnew file mode 100644index 0000000..b120619--- /dev/null+++ b/specs/site-display-names/decision_log.md@@ -0,0 +1,31 @@+# Decision Log: Site Display Names++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-09-04 | Smolspec, not a full spec | `Site.displayName` is an existing stored, synced, archived column, so no schema, archive-format or sync-attribute change; the only user-owned question (which surfaces show the name) was answered up front: all of them |+| Q2 | 2026-09-04 | Names reach the screens through an app-layer lookup (`SiteNames`, read once per `refreshAll` from a new `siteNames()` read), not by stamping them on core snapshots | The alternative — a `siteName` on `WorkSiteMembershipSnapshot` and `EntrySnapshot` filled from a name map passed down the repository's `snapshot` helpers — touches five signatures and about 25 call sites, needs a defaulted parameter the codebase explicitly avoids ("Stated, not defaulted", `LibraryRepository.swift:1159`), and would have to reach every reader-facing read (`work(id:)`, entry detail, `workDestinations`, the merge previews), two of them ceilinged. The lookup touches no snapshot, no budgeted read and no non-reader path, and `AppLibraryModel` already derives presentation per publication (`WorksFilterOptions(works:)`). Undoing it later, should a snapshot field ever be wanted, is additive |+| Q3 | 2026-09-04 | The lookup is a SwiftUI environment value, the app's first custom `EnvironmentValues` entry | Eight views on independent routes render a site; threading a parameter through each screen's model would change eight initialisers to carry one read-only map. `SiteLabel` reading it itself leaves its three callers untouched |+| Q4 | 2026-09-04 | Empty input resets the name to the hostname rather than being refused | The hostname is the constructor's default and what "no custom name" has always meant in the store, import (`LibraryRepository+ConfirmImport.swift:462`) and the name rule; a distinct "clear" control would be a second way to reach the same state |+| Q5 | 2026-09-04 | No uniqueness rule on display names; a shared name shows its hostname appended on every surface outside the Sites list | Sites are keyed by hostname everywhere, so a shared name is a presentation collision. Applying the disambiguation library-wide in one place (`SiteNames.label(for:)`) keeps the merge preview and the work detail row as unambiguous as the works filter. Work types enforce uniqueness because a type *is* its name; a site is not |+| Q6 | 2026-09-04 | One name rule for a hostname with several rows: first custom name in resolution order, else the hostname, implemented once in `siteDisplayName` and used by the Sites list, the lookup, export and the archive projection | The winner is content-dependent by design (`IdentityResolution.swift:30-40`): a teach on either row flips it, so a winner-only read would silently revert a name after a teach on a duplicated hostname. `SiteReconciler.applyUnion` never writes a name, so changing only `SiteUnionProjection` would have altered the archive value and nothing on screen |+| Q7 | 2026-09-04 | The rename writes every Site row for the hostname | Converges the store without giving the reconciler a write to a synced column, and makes the no-op check and the name rule agree on a healthy library. A row that arrives later unnamed is covered by Q6 |+| Q8 | 2026-09-04 | Import precedence unchanged: the archive fills only an unnamed local site | A custom name set on device is newer than any archive's guess about it, and the exporting device's own name reaches the fleet through sync. Consequence: a site renamed back to its hostname is unnamed again and an import may name it |+| Q9 | 2026-09-04 | The site link opens `https://<hostname>/`, labelled with the hostname | A Site stores only a hostname; labelling the link with the name would hide the one place the screen says where it goes |+| Q10 | 2026-09-04 | Glyph colour, identifiers, picker tags and filter keys stay hostname-keyed | Identity is the hostname; the name is presentation. Recolouring on rename would break Req 9.2 of `polish-and-export`, and the UI tests address rows by hostname |+| Q11 | 2026-09-04 | Share extension out of scope | It renders no hostname text today, so there is nothing to replace |+| Q12 | 2026-09-04 | Destructive confirmations ("Remove from <hostname>", the articles switch) keep the hostname | A confirmation names what is about to change, and a destructive sentence must not depend on a presentation label |+| Q13 | 2026-09-04 | The no-op check compares the trimmed string exactly, with no case folding or normalisation | Site names have no uniqueness rule, so `WorkTypeName.normalize` has nothing to compare against; a case-only change is a rename the reader meant |+| Q14 | 2026-09-04 | A rename against a hostname with no Site row throws `invalidInput`, not `recordNotFound` | `recordNotFound` is UUID-keyed and a Site has no UUID; the state is unreachable from the Sites screen (rows are never deleted), so no new error case is warranted |+| Q15 | 2026-09-04 | A new `SiteName` validator rather than reusing `WorkTypeName.validate` | `validate` returns `.empty` first, which is the reset case here, and `WorkTypeNameError` carries a case sites cannot produce; only the forbidden character set is shared |+| Q16 | 2026-09-04 | Accessibility labels read the display label, not the hostname | VoiceOver hears what sighted readers see; the disambiguation of Q5 restores the hostname exactly where a sighted reader would need it too |+| Q17 | 2026-09-04 | No length bound on a name | Work-type names have none either; every renderer is one line with tail truncation, and the navigation title truncates natively |+| Q18 | 2026-09-04 | The `\.siteNames` environment value is set once in `ContentView.readyContent`, not on each screen `AppScreens` builds as the smolspec's Surfaces paragraph says. The Mac's `Settings` scene, built in `AsterismApp` outside `ContentView` altogether, gets its own write — it inherits nothing from that one | Both navigation trees declare their `navigationDestination`s outside the screens `AppScreens` builds — entry detail is pushed from `screens.recent()`'s call site — so a per-screen write would have missed a surface on the exhaustive list. One write at the top of the window reaches both trees, everything they push and the sheets attached above it |+| Q19 | 2026-09-04 | Two accessibility sentences outside the smolspec's exhaustive list — `WorksRowPresentation.openLabel` and `WorkMergeView.destinationLabel` — read the display label too | Both describe rows that draw a visible `SiteLabel`; leaving them on the hostname would have VoiceOver say one thing while the screen shows another, which Q16 forbids |+| Q20 | 2026-09-04 | Entry detail's capture details use `name(for:)` with the hostname beneath where they differ, not `label(for:)` | That surface always shows the hostname under a custom name, so the appended-hostname spelling of Q5 would print the hostname twice in a collision; the identity is already visible |+| Q21 | 2026-09-04 | The site screen's Rename button uses `.constellationSecondary`, not the gradient primary `WorkTypeDetailView` gives its rename | `polish-and-export` Req 9.1 allows one gradient primary per screen, and this screen's is already the re-teach button; everything else about the Name section mirrors `WorkTypeDetailView` |+| Q22 | 2026-09-04 | The Sites UI journey renames both seeded sites: `composed.test` carries the link-row and list-row checks, `id.test` the work-detail check | `seedComposedFixture` has no Work on `composed.test` — its one capture there is an unattached entry — and the fixture's only Work is on `id.test`; renaming both keeps the journey on the seeded fixture without a fixture change, and the work page still reads its name through `\.siteNames`, so the non-Settings surface the Risks section asks for is checked |+| Q23 | 2026-09-04 | `SiteNames` is declared `nonisolated` | The project builds with `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, so an unannotated `label(for:)` is main-actor isolated — and one of its callers, `WorksFilterPresentation.activeLabels`, is a `nonisolated` static. The verification build reported a new main-actor-isolation warning at that call (`WorksListOptions.swift:318`). `SiteNames` is a `Sendable` value with two pure reads, so `nonisolated` is what it should have said in the first place; annotating the caller `@MainActor` instead would have moved a presentation helper onto the actor to suit one value it reads |+| Q24 | 2026-09-04 | A site still named by its own hostname never gets the appended-hostname suffix, even when another site's custom name equals that hostname | It would read `x.test (x.test)`, and in that collision the plain hostname is already the spelling that says which site it is; the other site wears the suffix. Pinned by `SiteNamesTests` |+| Q25 | 2026-09-04 | Import's "unnamed local site" test is `SiteName.isCustom`, the name rule's own predicate | One definition of "custom name" for the name rule, the rename's reset and import precedence (Q8); the only input the two spellings disagreed on was a whitespace-only stored name, which no write path produces |
specs/site-display-names/implementation.md Added +337 / -0
diff --git a/specs/site-display-names/implementation.md b/specs/site-display-names/implementation.mdnew file mode 100644index 0000000..c49132a--- /dev/null+++ b/specs/site-display-names/implementation.md@@ -0,0 +1,337 @@+# Implementation: Site Display Names++T-2303, branch `T-2303/site-display-names`. Base for every diff below is `5f85d29`+(the smolspec commit).++This records what shipped, what the verification run measured, and the+surface-by-surface re-check of the smolspec's exhaustive list. The design and the+decisions are not restated here — they are in `smolspec.md` and `decision_log.md`.++## What shipped, per phase++**Phase 1 — the core rule and the write.** `60bf373` adds+`LibraryProviding.renameSite(hostname:to:)` and its `LibraryRepository`+implementation under the exclusive lock, with `SiteName` (trim, validate) beside+it and `WorkTypeName.forbidden` made internal so the two agree about what a+single-line name is. `0f65710` replaces `siteDisplayName(_ site:hostname:)` with+the one rule over a hostname's rows in `SiteResolutionOrder` — first custom name,+else the hostname — and points `sites()`, the markdown export's front matter and+`SiteUnionProjection` at it. `10bf036` adds the `siteNames()` read and the mock's+stubs. Changelog `72c916a`, specs overview `0390a61`.++The review fixes move that rule onto the name rule itself, as+`SiteName.displayName(of:hostname:)` — beside the trim, the validator, the+rename's `stored(_:hostname:)` and the `isCustom` predicate import now tests+"unnamed" with (Q25). `LibraryRepository` keeps no copy of it.++**Phase 2 — every surface names its site.** `4d81f16` adds the app-layer+`SiteNames` lookup (`name(for:)`, `label(for:)` with Q5's appended hostname), the+`\.siteNames` environment entry, and the `refreshAll` read that publishes it with+the cycle's other snapshots. `4c9b6ee` moves every surface on the smolspec's list+onto it. Decisions Q18–Q20 in `6ad0b6b`, changelog `b72a7ad`.++**Phase 3 — the Sites screen.** `d7e3e2f` gives `SiteDetailView` its Name section+and `SiteDetailModel` the draft, `canRename`, `currentName`, `rename()` and the+rejection wording, with the list reload threaded through `SiteDetailModelBuilder`.+`0f1e752` adds the site link row. `1a7c197` adds the UI journey+(`testARenamedSiteCarriesItsNameToTheListAndOffSettings`). Decisions Q21–Q22 in+`e0d8a19`, changelog `2bfbb7f`.++**Phase 4 — verification.** This commit: the surface re-check below, one warning+fix (`SiteNames` declared `nonisolated`, Q23), and this file.++## Verification run (2026-09-04)++| Target | Result |+|---|---|+| `make test-core` | **exit 0.** 2,252 test entries across the package suites, no failures, no known issues. Re-run on the final tree, also exit 0. |+| `make test-quick` | **exit 0.** 1,125 tests in 84 suites passed (23.4 s of test time), preceded by a clean `build-mac` with the Mac appex asserted on disk. |+| `make test-ui` | **108 tests, 2 skipped, 3 failures — the three pre-existing ones and no others** (2,697 s). Every other suite green: `AccessibilityJourneyUITests` 10, `AsterismUITests` 2, `AsterismUITestsLaunchTests` 4, `CharacterExtractionOutcomeUITests` 2, `CharacterExtractionUITests` 9, `ComposedSurfaceUITests` 12, `DuplicateResolutionUITests` 7, `LibraryDiagnosticsUITests` 5, `PendingCaptureSettingsUITests` 1, `RecentAndEntryDetailUITests` 7, `ReparseSheetUITests` 1, `SearchUITests` 5, **`SitesSettingsUITests` 4**, `StatsUITests` 10, `WorkDetailActionsUITests` 13, `WorkTypesSettingsUITests` 3, `WorksAndAssignmentUITests` 4, `WorksListOptionsUITests` 4. |++The three `make test-ui` failures are `M4ScaleRecentPerformanceUITests`+(`testSeededScaleM4DuplicateSiteRowsScenarioReachesRecent` 182.7 s,+`testSeededScaleM4ScenarioReachesRecent` 183.9 s,+`testTruncationFooterOpensWorksRoot` 183.8 s), each+`XCTAssertTrue failed - the seeded-scale-m4 scenario never reached Recent — check+that the seed did not throw`. The 5,000-entry seed overruns the suite's 180 s+`seedTimeout` by three to four seconds on this machine; `docs/agent-notes/testing.md`+and `CHANGELOG.md` record them as pre-existing, reproduced on the branch point+with every local change stashed. This run is "those three and no others", which is+what a green `make test-ui` means here.++**Compiler warnings.** Checked the way `docs/agent-notes/testing.md` requires: all+35 changed Swift files were `touch`ed, both builds run unwrapped (`PIPE_PRETTY=`),+and the raw logs grepped for `warning:`. The package log recompiled every changed+`AsterismCore` file and emitted none. The app log emitted one **new** warning —+`WorksListOptions.swift:318`, `call to main actor-isolated instance method+'label(for:)' in a synchronous nonisolated context`, from `activeLabels` reading+the lookup — fixed by declaring `SiteNames` `nonisolated` (Q23) and confirmed gone+on the next unwrapped build. Every warning still in the log is pre-existing and on+a line this branch did not touch: `WorksListOptions.swift:86` and `:316` (the same+isolation shape, on expressions that predate this work), `DuplicateSurfaceTests`,+`CharacterExtractionCoordinatorTests`, `CharacterReviewModelTests`,+`OptionalSequenceTeachingMessagesTests`, `SettingsImportTests`, and eight+`#expect` macro expansions in `WorksListOptionsTests` — all on `#expect` lines+(274, 282, 290, 318, 326, 332, 444) that compare `WorksFilter`,+`WorksTypeSelection` and `WorksFilterOptions`, none of them added by this branch,+whose own new assertions compare `[String]`. Those pre-existing warnings are also+the control that proves the log is a real recompile rather than a warm no-op.++**Two `make test-quick` runs failed and were re-run green**, each on a *different*+`ComposedTeachingViewModelTests` case+(`applyingRegeneratesThePreview`, `effectiveRuleFollowsSuggestionOnTaughtSide`),+both reading a `MockLibraryProvider` request-log entry that belonged to the other+in-flight projection. That is the flake family+`docs/agent-notes/testing.md` records under "Adding recorded state to+`MockLibraryProvider` needs a lock" — a different name each run. Confirmed not+this branch's: the suite passes in isolation (72 tests, exit 0), the full run+passes with the change in place, and the same full run passes with the change+stashed. Nothing in composed teaching reads `SiteNames`.++## Surface re-check++Every surface on the smolspec's exhaustive list, plus the identity-keyed things+that had to *stay* hostnames. Line numbers are this commit's.++| Surface | File:line | What it shows | Verdict |+|---|---|---|---|+| Sites list, name line | `Views/SitesView.swift:69` | `row.displayName` — the `sites()` read's name, resolved by the one rule (`LibraryRepository+Sites.swift:256`) | ✅ |+| Sites list, secondary line | `Views/SitesView.swift:78` | hostname · work count, only where `showsHostnameSeparately` (`ViewModels/SitesModels.swift:33`) | ✅ hostname stays |+| Sites list, row a11y | `Views/SitesView.swift:96–97` | identifier `site-row-<hostname>`, label reads `displayName` | ✅ Q10 + Q16 |+| Sites detail, title | `Views/SitesView.swift:208` | `model.currentName`, which follows a rename in place | ✅ |+| Sites detail, section header | `Views/SitesView.swift:142` | `model.site.hostname` | ✅ hostname stays |+| Sites detail, link row | `Views/SitesView.swift:158–164` | `Label(hostname)`, `https://<hostname>/`, a11y "Open \<hostname\>" | ✅ Q9 |+| Work detail, site row | `Views/WorkDetailView.swift:437` | `SiteLabel` → `label(for:)` (`Views/SiteLabel.swift:40`); identifier from `WorkDetailSitePresentation.siteIdentifier` | ✅ |+| Work URL site picker | `Views/WorkDetailView.swift:795` | `Text(label(for:)).tag(hostname)` | ✅ Q10 tag |+| Identity review menu | `Views/WorkDetailView.swift:863` | `Button(label(for:))`, identifier hostname-keyed | ✅ |+| "Remove from \<hostname\>" | `Views/WorkDetailView.swift:104`, `:311`, `:685`, a11y `:694` | hostname, untouched | ✅ Q12 |+| Works list rows | `Views/WorksView.swift:672` | `SiteLabel`; identifier `WorksRowPresentation.siteIdentifier(hostname)` | ✅ |+| Works row a11y sentence | `Views/WorksView.swift:508`, `:594` | `openLabel(for:siteNames:)` | ✅ Q19 |+| Works site filter menu | `Views/WorksView.swift:252–262` | `Text(label(for:))`; tag and identifier hostname-keyed | ✅ Q10 |+| Site filter option order | `ViewModels/WorksListOptions.swift:286–295` | `localizedStandardCompare` on the label, hostname tiebreak | ✅ Req 11 |+| Active-filter pill | `Views/WorksView.swift:310` → `WorksListOptions.swift:318` | `label(for:)` | ✅ |+| Merge preview site rows | `Views/WorkMergeView.swift:186` | `SiteLabel` | ✅ |+| Merge destination a11y | `Views/WorkMergeView.swift:134`, `:141–145` | `destinationLabel(_:unavailable:siteNames:)` | ✅ Q19 |+| Stats "Most read sites" | `Views/StatsView.swift:709–712` | `label(for: row.hostname)`; `stats-top-site-row` identifier unchanged; `StatsDerivation` still ranks by hostname | ✅ |+| Entry detail, capture details | `Views/EntryDetailView.swift:400–411` | `name(for:)`, hostname beneath where the two differ; `entry-detail-hostname` unchanged | ✅ Q20 |+| Recent row a11y label | `Views/RecentView.swift:851` | `label(for: row.hostname)` | ✅ Q16 |+| Site glyph colour | `Views/SiteLabel.swift:35`, `SitesView.swift:66`, `RecentView.swift:971` | `SiteGlyph(hostname:)` everywhere | ✅ Q10 |+| Articles switch confirmation | `Views/SitesView.swift:212`, `ViewModels/SitesModels.swift:92–97` | "Treat every page as an article?" / "…notes from this site…" — names no site at all | ✅ unchanged |+| Environment write | `ContentView.swift:493` | one `.environment(\.siteNames, …)` above both trees and the sheets | ✅ Q18 |+| Environment write, Mac Settings scene | `AsterismApp.swift:71` | its own `.environment(\.siteNames, …)`: the scene is built outside `ContentView` and inherits nothing from the write above | ✅ Q18 |+| Import precedence | `LibraryRepository+ConfirmImport.swift` | not in `git diff 5f85d29..HEAD --name-only` | ✅ Q8 |+| `SiteReconciler.applyUnion`, `StatsDerivation` | — | neither file is in the diff | ✅ |++A grep for reader-facing `Text(… hostname …)` and `.accessibilityLabel(… hostname+…)` across `Asterism/Asterism/Views` returns only the rows above plus three that+are deliberately hostnames and are not on the smolspec's list: the diagnosis+screen's `Re-teach <hostname>` action (`MaintenanceViews.swift:208`, `:213`), the+work page's "Open this work's page on \<hostname\>"+(`WorkDetailView.swift:471` — a link, labelled by where it goes, as Q9 has the+site link), and the composed URL editor's raw URL preview+(`ComposedURLDetailsEditor.swift:126`).++## Pre-push review (2026-09-04)++Four parallel review passes (code reuse, code quality, efficiency, spec and+tests) over the merge-base diff. No blocking or major findings. Applied in+commit `14ae550`: `SiteName` now owns every site-name rule (`stored`,+`isCustom`, `displayName(of:hostname:)`, `SiteRenameOutcome`) and import's+"unnamed" predicate shares it (Q25); `SiteResolutionOrder.orderedByHostname`+backs `winnersByHostname`, `sites()` and `siteNames()`; the macOS `Settings`+scene gets its own `\.siteNames` write (Q18 amended); filter labels are+computed once before sorting; `goBack()` moved to `UIJourneySupport`; a+loser-only-name export test was added; Q24 records the own-hostname suffix+exemption. Skipped: carrying site labels inside `WorksFilterOptions` (a+signature change existing tests construct), replacing the `onChanged`+threading (the smolspec's chosen design), and three efficiency nits off any+budgeted path. `make test-core`, `make test-quick` and `SitesSettingsUITests`+green after the fixes, no new warnings.++## Explanation++Written by the pre-push review (2026-09-04) at three levels, as a check that+every requirement in `smolspec.md` can be explained from the code.++### Beginner Level++#### What Changed / What This Does++Asterism keeps a list of the websites a reader captures stories from. Until+now every screen called a site by its web address, such as `royalroad.com`.+This change lets the reader give a site a friendlier name from that site's+own settings page, and then shows that name everywhere the app mentions the+site: the list of works, the detail page of a work, the filter menu, the+statistics page, the capture details of an entry, and what VoiceOver reads+out loud.++The settings page for a site also gains a link that opens the site in the+browser.++#### Why It Matters++Web addresses are not how people think about the places they read. A reader+who follows a serial on a site with a long or cryptic hostname now sees the+name they chose, in every place, without having to remember the address. If+two sites end up with the same name, the app adds the address in brackets so+they never look identical.++#### Key Concepts++- **Hostname**: the address part of a web link, like `example.com`. In this+  app it is the site's identity: nothing changes it, and everything that+  needs to tell sites apart uses it.+- **Display name**: the label the reader sees. It already existed as a stored+  field, and every site's default display name was its own hostname. This+  change adds the first way to set it.+- **Lookup**: a small table the app builds after every refresh, mapping each+  hostname to the name to show. Screens read from that table instead of+  asking the database themselves. Think of it as a name tag every site wears+  for the rest of the app.+- **Environment value**: a SwiftUI mechanism for handing one piece of data to+  every screen below a certain point, without passing it through each screen+  by hand. The lookup travels this way.++---++### Intermediate Level++#### Changes Overview++- `AsterismCore`: `SiteName` (new) owns trimming, validation, the stored form+  (empty resets to the hostname) and the name rule. `LibraryProviding` gains+  `renameSite(hostname:to:)` returning `SiteRenameOutcome` and a `siteNames()`+  read returning `[hostname: resolved name]`. `sites()`, the markdown export+  site line and `SiteUnionProjection` all use the one name rule.+- App layer: `SiteNames` (new value type) wraps the read, offering `name(for:)`+  and `label(for:)`; `AppLibraryModel.refreshAll` publishes it beside the+  snapshots and `ContentView` sets it as the `\.siteNames` environment value.+  `SiteLabel` and the other listed surfaces read it.+- Sites screen: `SiteDetailView` has a Name section and a `Link` row;+  `SiteDetailModel` owns the draft, `canRename`, `rename()`, `currentName` and+  `siteURL`; an `onChanged` closure reloads the list behind the screen.+- Tests: core tests for every rename arm and the three name-rule arms, app+  unit tests for the lookup, the filter sort and the detail model, and a+  `SitesSettingsUITests` journey that renames both seeded sites.++#### Implementation Approach++The rename is a repository write under the exclusive lock, shaped like+`renameWorkType`. It fetches every `Site` row for the hostname and writes+each one. A hostname can legitimately have several rows (CloudKit merges mint+duplicates that a reconciler later consolidates), and the row that "wins"+resolution is content-dependent, so writing only the winner would let a later+teach flip the visible name. A rename to the value every row already holds+returns before saving, so a no-op produces no sync traffic.++Reading goes through one rule: the first custom name among the hostname's+rows in resolution order, else the hostname. "Custom" means non-blank after+trimming and different from the hostname, because the constructor and import+have always used the hostname to mean "unnamed". The same rule serves the+Sites list, the app-wide lookup, export and the backup archive projection.++Names reach views as an environment value rather than a snapshot field.+`SiteLabel` reads it itself, so its three callers did not change. The lookup+computes its shared-name set once at construction, so `label(for:)` is two+hash lookups per call and safe inside body passes.++#### Trade-offs++- **Lookup versus snapshot field** (Q2): stamping the name on the work and+  entry snapshots would have touched five repository signatures, about 25+  call sites and two budgeted reads. The lookup is one extra small read per+  refresh and no core snapshot change. The cost is that a screen can, for one+  refresh cycle, show rows from a newer snapshot with names from an older+  lookup; the generation guard publishes them together, so this does not+  happen in practice.+- **Environment value versus threading** (Q3): eight views on independent+  routes name a site. Threading one read-only map through eight initialisers+  was judged worse than the app's first custom environment entry.+- **No uniqueness** (Q5): sites are keyed by hostname, so a shared name is a+  presentation collision, solved by appending the hostname at every surface+  outside the Sites list, which already shows the hostname beneath.+- **Every row written** (Q7) instead of giving the reconciler a write to a+  synced column.++---++### Expert Level++#### Technical Deep Dive++- **Name rule and reset agree by construction.** `SiteName.stored` produces+  what a rename writes (trimmed, or the hostname when empty), and+  `SiteName.isCustom` is the predicate the name rule, the no-op check and+  import precedence all use. A site reset to its hostname is therefore+  "unnamed" to import again (Q8), which is the intended precedence.+- **Resolution order is reused, not reimplemented.** `sites()`, `siteNames()`,+  `fetchSites` and `SiteUnionProjection` all pass rows through+  `SiteResolutionOrder`, so "winner first" means the same row everywhere; the+  winner still supplies the teaching state, all rows supply the name.+- **No-op detection uses the save strategy.** `withLockedContext` builds a+  fresh `ModelContext` per call, so a test cannot inspect `hasChanges`+  afterwards; the core test injects an instrumented save strategy and asserts+  zero attempts.+- **Concurrency.** The project builds with default main-actor isolation.+  `SiteNames` is declared `nonisolated` (Q23) because the `nonisolated`+  `WorksFilterPresentation.activeLabels` calls it; it is a pure `Sendable`+  value, so this is the honest declaration, not a workaround.+- **Environment coverage.** Both navigation trees push entry detail from+  outside the screens `AppScreens` builds, so a per-screen write would have+  missed a listed surface; the write sits at the top of `ContentView`'s ready+  content (Q18). The macOS `Settings` scene is built outside `ContentView`+  and receives its own write, found by this review.+- **Identifiers stay hostname-keyed** (Q10) so UI tests address rows by+  identity and a rename cannot break them; accessibility labels read the+  display label (Q16).++#### Architecture Impact++- `LibraryProviding` grows by two requirements; the mock provider stubs them+  in the existing call-count and preset-result shape.+- `SiteName` becomes the single home for site-name semantics across the+  repository, the projection, import and the app model. Any future rule+  change (a length bound, normalisation) has one place to go.+- The environment-value pattern is now established for app-wide, read-only,+  per-refresh presentation data. A second such value should follow the same+  shape rather than a snapshot field.+- No schema, archive-format or sync-attribute change; the archive value for a+  consolidated hostname can now differ only for a loser-only custom name,+  which no library contains today, and `BackupGoldenExportTests` bytes are+  unchanged.++#### Potential Issues++- **Half-named duplicated hostname.** If only a losing row carries a name,+  the Sites screen shows that name, `canRename` compares against it and the+  Rename button stays disabled, so the rows converge only on a different+  rename. Harmless: the visible name is already right, and the reconciler's+  next consolidation resolves the rows.+- **Two devices renaming concurrently** converge last-writer-wins per row+  through CloudKit with no ordering evidence, because `Site` has no+  modification timestamp. Accepted for a single-reader library.+- **Two refreshes per rename.** `onMutation` runs the full snapshot refresh+  and `onChanged` reloads the Sites list, which re-enumerates the membership+  table for counts. Once per tap; only worth revisiting if renames feel slow+  on a large library.+- **`label(for:)` collision spelling.** A site custom-named to another site's+  hostname wears the suffix; the site that still carries that hostname does+  not (Q24). Pinned by a unit test, but worth remembering when reading UI+  test assertions.++### Completeness Assessment++**Fully implemented**: every Requirements bullet in `smolspec.md` (the rename+with its trim, reset and refusal rules; the one name rule across the Sites+read, lookup, export and archive projection; every-row write and no-op+no-save; no uniqueness with hostname disambiguation; the exhaustive surface+list; hostname kept where identity matters; hostname-keyed identifiers and+label-reading accessibility; hostname-derived glyph colour; the link row;+refresh without relaunch; label-sorted filter options; import precedence+unchanged). The Test Plan's three tiers all exist.++**Partially implemented**: none.++**Missing**: nothing from the spec. The Mac rendering of the new Name section+and link row remains the owner's manual check, as the Mac build is compiled+but never launched by an agent.
specs/site-display-names/smolspec.md Added +54 / -0
diff --git a/specs/site-display-names/smolspec.md b/specs/site-display-names/smolspec.mdnew file mode 100644index 0000000..3464f7c--- /dev/null+++ b/specs/site-display-names/smolspec.md@@ -0,0 +1,54 @@+# Site Display Names++Transit: T-2303 "More details for sites".++## Overview++A site is named to the reader by its hostname everywhere but the Sites settings screen, and nothing lets the reader change that. `Site.displayName` already exists as a stored, synced, archived column; `Site.init` fills it with the hostname, so every row a capture has ever minted carries its hostname as its name. This change adds the one missing write — a rename on the site's settings screen — and makes every surface that names a site show the display name instead of the hostname. The same screen gains a link that opens the site.++## Requirements++- The system MUST let the reader set a site's display name from that site's screen under Settings → Sites. Leading and trailing whitespace is trimmed; a name that is empty after trimming resets the site to its hostname; a name containing line breaks or control characters is refused with a reason the screen turns into a sentence. No case folding or Unicode normalisation is applied: a case-only change is a rename. There is no length bound; every surface that renders a name truncates it to one line.+- A hostname's display name MUST be resolved by one rule everywhere: the first custom name among its Site rows in `SiteResolutionOrder` (winner first), otherwise the hostname. A custom name is non-empty and differs from the hostname. The Sites list, the app-wide name lookup, markdown export and the archive projection of a consolidated site MUST all use it, so a duplicate row minted unnamed by another device's capture, or a winner that flips after a teach, never hides a name.+- The rename MUST write every Site row for the hostname, so the store converges without a reconciler change. When the trimmed name equals every row's stored name it MUST report success and leave the context with no pending changes, so a no-op rename produces no sync churn. A hostname with no Site row is refused as invalid input naming the operation.+- Two sites MAY share a display name; the system MUST NOT enforce uniqueness. Wherever a name is shown outside the Sites list, an option or row whose name another site in the library shares MUST show the hostname appended, so no two sites ever read alike on one surface.+- The system MUST show the display name (with that disambiguation) in place of the hostname on every reader-facing surface that names a site. The list is exhaustive: the Sites list and detail; the work detail site row, Work URL site picker and identity review menu; the works list rows, their site filter menu and the active-filter pill; the merge preview's site rows; Stats "Most read sites"; entry detail's capture details; and the Recent row's accessibility label. Where the name is the hostname, nothing on these surfaces changes.+- The hostname MUST stay visible where identity matters: the Sites list's secondary line and the Sites detail section header (both already do), entry detail's capture details beside the name where the two differ, and every destructive confirmation that names a site — "Remove from <hostname>" on the work detail and the articles switch — which keep the hostname unchanged.+- Accessibility identifiers, picker tags and filter keys MUST stay hostname-keyed; accessibility labels MUST read what sighted readers see, the display name with its disambiguation.+- Site glyph colour MUST keep deriving from the hostname (`polish-and-export` Req 9.2), so a rename never recolours a site.+- The site detail screen MUST offer a link that opens `https://<hostname>/` in the system browser, labelled with the hostname. Where that URL cannot be constructed the link is omitted.+- After a rename, the Sites list on return and every other surface after the next snapshot refresh MUST show the new name without relaunching the app.+- The works filter's site options SHOULD sort by their label (`localizedStandardCompare`), hostname as tiebreak, matching the Sites list's order.+- Backup import MUST keep its current precedence: an archive's name fills an unnamed local site and never overwrites a custom one. A site renamed back to its own hostname is unnamed again and an import may name it.++## Implementation Approach++**Name rule** — `LibraryRepository.siteDisplayName(_:hostname:)` (`Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Sites.swift:165`) takes the hostname's rows in resolution order instead of one Site and applies the rule above. Callers: `sites()` (`:13`), the export site line (`LibraryRepository+Export.swift:174`) and `SiteUnionProjection.displayName(survivor:hostname:)` (`SiteUnionProjection.swift:270`), which reads only the survivor today and feeds the archive value at `BackupArchiveProjection.swift:739`. `SiteReconciler.applyUnion` writes no name and is untouched.++**Write** — `LibraryProviding.swift` gains `renameSite(hostname:to:)` returning `SiteRenameOutcome` (`renamed` / `rejected(SiteNameRejection)`), implemented in `LibraryRepository+Sites.swift` under the exclusive lock as `renameWorkType` is (`LibraryRepository+WorkTypes.swift:169`), fetching every Site row for the hostname and writing each. A new `SiteName` enum in `AsterismCore` offers `trimmed` and `validate`; `validate` returns only `.containsLineBreaksOrControlCharacters` — an empty name is the reset, not a rejection — and shares `WorkTypeName.forbidden` (`WorkTypeName.swift:67`), which becomes internal. A hostname with no row throws `LibraryRepositoryError.invalidInput(operation:reason:)` (`CrossProcessLibraryLock.swift:7`). `Asterism/AsterismTests/Helpers/MockLibraryProvider.swift` gains the stub.++**Name lookup** — a new `siteNames()` read on `LibraryProviding` returns `[hostname: resolved name]` from one Site fetch grouped by hostname through the name rule; `sites()` is not reused because it also enumerates the membership table for counts. `AppLibraryModel.refreshAll` (`Asterism/Asterism/ViewModels/AppLibraryModel.swift:839`) reads it beside `recentPresentation` and `works()` and publishes a `SiteNames` value under the same generation guard. `SiteNames` (`ViewModels/SiteNames.swift`) offers `name(for:)` and `label(for:)`, the latter appending the hostname when another hostname in the map resolves to the same name, computed once at construction. No snapshot type, repository read path or non-reader path changes.++**Surfaces** — `SiteNames` reaches views as a SwiftUI environment value (`\.siteNames`, the app's first custom `EnvironmentValues` entry) set where screens are built: `Layout/AppScreens.swift` and `ContentView.swift:542`; sheets inherit it. `Views/SiteLabel.swift` reads it and shows `label(for:)` beside the glyph it still colours from the hostname, so its callers (`WorkDetailView.swift:434`, `WorksView.swift:655`, `WorkMergeView.swift:177`, which renders `WorkMergeSiteOutcome` rows) are unchanged. `WorksFilterOptions.hostnames` (`WorksListOptions.swift:216`) stays `[String]` keys, sorted by label; the `filterPicker` label closure (`WorksView.swift:248`) and `WorksFilterPresentation.activeLabels` (`:290`) take the lookup. `WorkDetailView.swift:788` and `:851` label by lookup; `removeFromSiteLabel` (`:101`) is unchanged. `StatsView.swift:705` labels `StatsSiteRankingRow.hostname` through the lookup; `StatsDerivation.swift` is unchanged. `EntryDetailView.swift:390` shows the label with the hostname beneath where they differ. `RecentView.swift:845` reads the label into its accessibility sentence.++**Sites screen** — `SiteDetailView` (`Views/SitesView.swift`) gains a Name section — text field prefilled with the current name, Rename button — shaped like `WorkTypeDetailView` (`WorkTypesView.swift:153`), and a `Link` row in the `WorkDetailView.swift:459` pattern. `SiteDetailModel` (`ViewModels/SitesModels.swift`) owns the draft, `canRename`, the rejection wording, `rename()` and a `currentName` the navigation title follows. The list reloads through an `onChanged` closure the detail model calls after a rename: `SitesListView.detailModel` (`SitesView.swift:12`) gains the closure parameter and passes `model.load`, threaded through `SettingsView.swift:47` and `:86` to `AppLibraryModel.siteDetailModel(for:onReteach:)` (`AppLibraryModel.swift:1104`), which also fires `refreshDiagnosesAndSnapshots` (`:1112`) so the lookup refreshes.++**Out of scope** — the share extension (it renders no hostname text); any schema, archive-format or sync-attribute change; renaming from any screen but the site's own; a uniqueness rule; import precedence; a modification timestamp on `Site`; a reconciler write.++## Test Plan++- `AsterismCoreTests`: rename arms — trim, reset on empty, control-character refusal, no-op leaves no pending changes, unknown hostname throws, every duplicate row written; name rule — winner named, only a loser named, none named; `siteNames()` shape; `SiteUnionProjectionTests` pins the archive value for a loser-only name; `SitesListReadTests` reads the resolved name.+- `AsterismTests`: `SitesModelTests` for `canRename`, the rejection sentence and `currentName`; `SiteNames` disambiguation and lookup fallback; `WorksListOptions` label sort and `activeLabels`; `StatsView`/`EntryDetail` presentation where unit-testable.+- `AsterismUITests/SitesSettingsUITests` on `seeded-composed`: rename `composed.test`, the list shows the name over the hostname on return, the link row exists; a work on that site shows the name on its detail row.++## Risks and Assumptions++- Risk: the environment value is unset on some presentation path and a surface silently shows hostnames | Mitigation: `SiteNames` defaults to an empty lookup that returns the hostname, and the UI journey checks a non-Settings surface.+- Risk: the archive value for a consolidated hostname changes when only a loser row carries a name | Mitigation: no such row exists today (nothing has ever written a custom name), so `BackupGoldenExportTests` bytes are unchanged; the new arm is pinned in `SiteUnionProjectionTests`.+- Assumption: `Site` has no modification timestamp, so concurrent renames on two devices converge last-writer-wins per row through CloudKit with no ordering evidence. Acceptable for a single-reader library; a timestamp would be a schema change.+- Assumption: `seeded-composed` (`AppLibraryModel.seedComposedFixture`) seeds `composed.test` and `id.test` with constructor-default names, so the journey needs no fixture change.+- Assumption: one extra small read per `refreshAll` is noise beside the two reads already there; no budgeted core path changes.+- Prerequisite: none; every column and screen this touches exists.++## Escalation Note+This change was scoped as a smolspec. If implementation reveals ambiguity only the user can resolve, an irreversible boundary (public API, persisted schema, auth path), or a contested architectural choice, stop and escalate to the full spec workflow rather than deciding it inline.
specs/site-display-names/tasks.md Added +65 / -0
diff --git a/specs/site-display-names/tasks.md b/specs/site-display-names/tasks.mdnew file mode 100644index 0000000..ffcb27c--- /dev/null+++ b/specs/site-display-names/tasks.md@@ -0,0 +1,65 @@+---+references:+    - specs/site-display-names/smolspec.md+    - specs/site-display-names/decision_log.md+---+# Site Display Names++## Core: name rule, rename write, name read++- [x] 1. A hostname's display name resolves by one rule from all its Site rows — first custom name in resolution order, else the hostname — and the Sites list read, the markdown export site line and the archive projection of a consolidated site all use it <!-- id:wyotefb -->+  - smolspec.md Name rule; decision_log Q6. SiteReconciler.applyUnion is not touched.+  - Success: core tests cover a named winner, a loser-only name and no name; SiteUnionProjectionTests pins the loser-only archive value; BackupGoldenExportTests bytes are unchanged.+  - Stream: 1++- [x] 2. The library provider can rename a site: trimmed input, empty resets to the hostname, line breaks or control characters are refused with a reason, every Site row for the hostname is written, an unchanged name writes nothing, an unknown hostname is refused as invalid input <!-- id:wyotefc -->+  - smolspec.md Write; decision_log Q7, Q13, Q14, Q15. A SiteName validator shares WorkTypeName's forbidden set; renameSite returns renamed / rejected(reason).+  - Success: core tests cover every arm, including a duplicated hostname and the no-op leaving the context with no pending changes.+  - Stream: 2++- [x] 3. A siteNames read returns every hostname mapped to its resolved name from one Site fetch, and the app test target's mock provider stubs both it and the rename <!-- id:wyotefd -->+  - smolspec.md Name lookup. MockLibraryProvider stubs siteNames() and renameSite so the app test target compiles.+  - Success: a core test reads a library with a named winner, a loser-only name and an unnamed site and gets the resolved names.+  - Blocked-by: wyotefb (A hostname's display name resolves by one rule from all its Site rows — first custom name in resolution order, else the hostname — and the Sites list read, the markdown export site line and the archive projection of a consolidated site all use it)+  - Stream: 1++## App: lookup and surfaces++- [x] 4. A SiteNames lookup — a name, and a label that appends the hostname when another site shares the name — is published by AppLibraryModel on every refresh and reaches every screen as a SwiftUI environment value <!-- id:wyotefe -->+  - smolspec.md Name lookup and Surfaces; decision_log Q2, Q3, Q5. Published under refreshAll's generation guard; the environment value is set where screens are built (Layout/AppScreens.swift, ContentView.swift).+  - Success: unit tests cover the hostname fallback and the shared-name disambiguation; an unset environment yields hostnames.+  - Blocked-by: wyotefd (A siteNames read returns every hostname mapped to its resolved name from one Site fetch, and the app test target's mock provider stubs both it and the rename)+  - Stream: 1++- [x] 5. Every surface in the smolspec's exhaustive list shows the display label instead of the hostname, while identifiers, tags, filter keys, glyph colour and destructive confirmations stay hostname-keyed <!-- id:wyoteff -->+  - smolspec.md Surfaces; decision_log Q10, Q12, Q16. Surfaces: SiteLabel (so work detail row, works rows and merge preview follow), the Work URL site picker and identity review menu, the works site filter menu sorted by label and its active pill, Stats most-read sites, entry detail's capture details with the hostname beneath where they differ, Recent's row accessibility label.+  - Success: WorksListOptions tests pin label order and activeLabels; existing UI suites still pass while names equal hostnames.+  - Blocked-by: wyotefe (A SiteNames lookup — a name, and a label that appends the hostname when another site shares the name — is published by AppLibraryModel on every refresh and reaches every screen as a SwiftUI environment value)+  - Stream: 1++## Sites screen++- [x] 6. The site detail screen renames the site, shows a rejection as a sentence, follows the new name in its title, and the Sites list and the other surfaces show the new name without a relaunch <!-- id:wyotefg -->+  - smolspec.md Sites screen. A Name section with the current name prefilled and a Rename button, shaped like WorkTypeDetailView; SiteDetailModel owns draft, canRename, rejection wording, rename() and currentName.+  - An onChanged closure threaded through SitesListView, SettingsView and AppLibraryModel.siteDetailModel reloads the list and fires the snapshot refresh.+  - Success: SitesModelTests cover canRename, the sentence and currentName.+  - Blocked-by: wyotefc (The library provider can rename a site: trimmed input, empty resets to the hostname, line breaks or control characters are refused with a reason, every Site row for the hostname is written, an unchanged name writes nothing, an unknown hostname is refused as invalid input)+  - Stream: 2++- [x] 7. The site detail screen offers a link that opens https://<hostname>/ in the system browser, labelled with the hostname, and omits it when the URL cannot be constructed <!-- id:wyotefh -->+  - smolspec.md Link; decision_log Q9. Link row in the WorkDetailView per-site link pattern with a stable accessibility identifier.+  - Success: a unit test covers URL construction for a normal and an unbuildable hostname.+  - Stream: 2++- [x] 8. A Sites settings UI journey renames a seeded site and sees the name in the Sites list, the link row, and a work's detail site row <!-- id:wyotefi -->+  - smolspec.md Test Plan. SitesSettingsUITests on seeded-composed: rename composed.test, return to the list and see the name over the hostname, find the link row, open a work on that site and see the name on its detail site row.+  - Success: passes under make test-ui beside the existing Sites tests.+  - Blocked-by: wyoteff (Every surface in the smolspec's exhaustive list shows the display label instead of the hostname, while identifiers, tags, filter keys, glyph colour and destructive confirmations stay hostname-keyed), wyotefg (The site detail screen renames the site, shows a rejection as a sentence, follows the new name in its title, and the Sites list and the other surfaces show the new name without a relaunch), wyotefh (The site detail screen offers a link that opens https://<hostname>/ in the system browser, labelled with the hostname, and omits it when the URL cannot be constructed)+  - Stream: 1++## Verification++- [x] 9. make test-core, make test-quick and make test-ui are green with no new compiler warnings, and every surface in the smolspec's list is re-checked against the code <!-- id:wyotefj -->+  - Success: the three targets pass with no new warnings; any divergence from the smolspec is recorded in decision_log.md as a Quick Decision.+  - Blocked-by: wyotefi (A Sites settings UI journey renames a seeded site and sees the name in the Sites list, the link row, and a work's detail site row)+  - Stream: 1

Things to double-check

Rebase before the PR.

origin/main has advanced by one merge (T-2052 background export, 90d9d17) since this branch was cut from 64b20b6. A plain diff against main shows that feature as deleted; the review used the merge base. Rebase or merge main first and re-run make test-quick.

Mac rendering.

The Name section, Rename button and Link row on the Mac Settings window compile but were never launched by an agent. The Settings scene now carries the siteNames environment write; nothing under Settings reads it yet.

Known simulator failures.

Full make test-ui passed on the final source tree except the three pre-existing M4ScaleRecentPerformanceUITests seed-timeout cases, which are recorded in the changelog and testing notes.

Concurrent renames.

Two devices renaming the same site converge last-writer-wins per row through CloudKit; Site has no modification timestamp. Accepted in the smolspec for a single-reader library.