Royal Road's URL chapter sequence is a site-wide id, not a chapter number. This branch withholds any number ≥ 10 000 from the spine's gutter (Q25) and uses an id-scale sequence as an ordering position so interludes sit between the chapters they were posted between (Q26). Two parallel reviewers; fixes committed as 552f1af.
chapterKey labels the gutter and is withheld at ≥ 10 000 (Q25); sequencePosition exists only where the sequence is an id and only orders (Q26).isChapterNumber(_:) at the two derivation entry points; the number-group parser is unlimited.Ready to push
The review caught a real regression in the first cut — a sequence that is a chapter number was given a position too, splitting sites like The Wandering Inn from their unmatched captures — and the fix narrows positions to id-scale sequences, so every site other than id-keyed ones orders exactly as on main. After the fixes make test-core (0 failures), make test-quick (Test Succeeded) and forced-recompile warning checks on both targets pass. The remaining user-visible gap (an id still names a note that has no title) is pinned by a test and owned by T-2270.
552f1af [feat]: a sequence is a position only where it is an identifier 1083e03 [feat]: the URL sequence orders the spine at any magnitude, labels it only under 10 000 e965aed [bug]: a number of 10 000 or more is never a chapter key On Royal Road the web address of a chapter carries a site-wide number in the millions. The app had been showing that as the chapter number and sorting by it. Now a number that big is never shown as a chapter — the gutter shows the number from the title ("Chapter 12") or nothing — but it is still used to put notes in order, so an interlude with no number in its title lands between the chapters it was posted between.
Chapter order on Royal Road works stories now reads correctly, and no other site changes.
ChapterKeyDerivation: largestChapterNumber = 9_999; key() accepts a group only when every part is within it; new position(chapterSequence:) returns the group only when it is not a chapter number. The parser itself is unlimited.WorkChapterRow.sequencePosition (defaulted) set in the repository read.WorkDetailModel.precedesInChapterOrder: positioned → keyed → rest, each by lastSharedAt then index; extracted from the sorted closure because the nested switches exceeded closure type inference.The first cut gave every sequence a position, which is wrong in exactly the case that used to work: a sequence ≤ 9 999 is the key (same scan, same start), so tiering it above title-keyed notes split one scale into two. Restricting positions to over-limit groups makes the tiers disjoint by construction: a row has a position iff its sequence is an id, and such a row's key (if any) came from the title. The comparator remains a strict weak ordering — tier, then position/key < (total, label tie-break), then date, then unique index.
WorkDetailReadTests so a future change is a choice, not drift.Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift
Why it matters. The whole behaviour change lives in two entry points and a 9 999 constant; a wrong predicate re-splits every site.
What to look at. ChapterKeyDerivation.swift — largestChapterNumber, key(chapterSequence:chapterTitle:), position(chapterSequence:), isChapterNumber(_:)
Asterism/Asterism/ViewModels/WorkDetailModel.swift
Why it matters. Ordering is the user-visible result; the tiers must be disjoint and the order total.
What to look at. WorkDetailModel.swift — precedesInChapterOrder
Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift
Why it matters. Pins the behaviour the quick fix leaves for T-2270 so it changes by decision, not by accident.
What to look at. WorkDetailReadTests.swift — chapterRowsCarryKeysAndRowTitles (siteWideID, siteWideInterlude, siteWideUntitled)
Royal Road's sequence is a site-wide id; the title rule gets its turn or the note stays unnumbered.
Revised at review from "any magnitude" so sites numbered by their URLs keep one scale with unmatched captures.
Closes the id-as-title case and decides the threshold's future.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | WorkDetailModel.swift — position tier | A sequence ≤ 9 999 is already the key, so giving those rows a position too pushed every title-keyed note below them; a site numbered by its URLs with one unmatched capture lost the interleaving `main` had. | `position()` yields a position only past the limit (an id); within it the key carries the number and the order is as before. Q26, design doc, CHANGELOG, implementation.md reworded. |
| major | implementation.md uncommitted | The Q25/Q26 updates to the spec's implementation note were working-tree changes, not in any commit. | Committed in `552f1af`. |
| minor | ChapterKeyDerivation.swift — `largest:` threading | The limit was plumbed through `numberGroup`/`firstNumberGroup` as a parameter with `.max` at one call site. | One `isChapterNumber(_:)` policy check at the two entry points; parser is unlimited. |
| minor | Stale doc comments | `key()` still said "sequence first (Q1)"; `ChapterSortOrder.chapter` still described two tiers; MARKs cited Q4/Q13 only; row helper doc omitted the position. | All rewritten. |
| minor | smolspec.md:53 — stale assumption | "`chapterSequence` digit runs are the chapter number on every taught site" was unannotated; the repo's convention is to strike in place naming the superseding decision. | Struck through with a Q25–Q26 note. |
| minor | Testing — equal positions | The `(.some, .some)` fall-through to `lastSharedAt` / offset was untested at the position level. | `chapterOrderBreaksPositionTiesByDate` added. |
| minor | Testing — unreachable fixtures | Model tests used positions like 100/200 beside keys 1/2, a shape derivation cannot produce. | Fixtures use id-scale positions only. |
| minor | Testing — sequence-only site-wide id | No repository case for an entry with an id sequence and no title: `displayTitle`/`rowTitle` become the raw id. | Pinned in `WorkDetailReadTests` as current behaviour (Req 8.12's fallback) with a pointer to T-2270; the id in the title is the gap that ticket closes. |
| minor | LibraryRepository+WorkDetail.swift:131 — id as title | Where a Royal Road note has no chapter title, the raw id is still the spine row title and the citation label; Q25 fixed the gutter only. | Skipped — `displayTitle`'s fallback is Req 8.12, shared with Recent and entry detail; changing it is T-2270's scope. Pinned by test and recorded on the ticket. |
| nit | docs/asterism-style-guide.md:99 — empty gutter | Spine-row entry had no empty-gutter case; Q25 makes it common on Royal Road. | Clause added. |
| nit | Testing — boundaries | `Chapter 9999` positive counterpart missing; Newest order untested against positions. | Both added. |
| nit | Citation gutter on Royal Road | Facts cited from a Royal Road note switch from an id link to the title number or arrow — user-visible, not named in Q25. | Skipped — correct by construction and the desired outcome; the design doc's §6 citation text already describes the key/arrow rule. |
| nit | Limit applies to the title path too | `Chapter 12345` is now unnumbered; Q25 argues from URL sequences only. | Skipped — Q25's decision sentence is general; no serial has ten thousand chapters either way. |
| nit | Efficiency | `key()` and `position()` each re-scan the sequence inside the locked read; `position()`'s label is never drawn; comparator ~n log n per load. | Skipped — microseconds at 500 rows; label is the `ChapterKey.<` tie-break. |
| nit | CHANGELOG — `[bug]` commit under `### Changed` | The file has a `Fixed` convention in past releases. | Skipped — the entry is one combined change (fix + ordering) and `[Unreleased]` has no `Fixed` section yet. |
| nit | Testing — multi-part position / overflow in a later part | No model-level multi-part position case; no `5-999…` position overflow case. | Skipped — `ChapterKey` ordering and `Int` overflow are covered at the derivation level. |
Click to expand.
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex 113f5bc..9ef98e5 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -30,14 +30,16 @@ public final class WorkDetailModel { /// Req 5.4, newest-first with cleaned display titles. public var chapterRows: [WorkChapterRow] { presentation?.chapterRows ?? [] } - // MARK: - The spine's two orders (`work-detail-reading-redesign` Q3, Q4, Q13)+ // MARK: - The spine's two orders (`work-detail-reading-redesign` Q3, Q4, Q13, Q26) /// Which order the spine is read in. The choice is the view's `@State` and /// lasts a visit (Q3): Newest is the reading default, and nothing is stored. public enum ChapterSortOrder: Equatable, Sendable { /// The repository's own order — `lastSharedAt` descending, untouched. case newest- /// Chapter key ascending, then the unnumbered notes (Q4).+ /// Notes whose URL sequence is a site-wide id, by that id; then notes+ /// with a chapter key, by key; then the rest (Q4, Q26). See+ /// `chapterOrder`. case chapter } @@ -54,8 +56,18 @@ public final class WorkDetailModel { } } - /// Q4's order: numbered notes first, by key ascending, ties oldest-first;- /// then the unnumbered ones, oldest-first.+ /// Q4's order as Q26 refined it: notes whose URL sequence is a site-wide+ /// id first, by that id ascending — numbered or not, so an interlude sits+ /// between the chapters it was posted between; then notes with a chapter+ /// key (from a title, or from a sequence that is a chapter number), by key+ /// ascending; then the rest. Every run reads oldest-first on a tie.+ ///+ /// The two scales cannot interleave (a site-wide id and a title's "12" say+ /// nothing about each other), so a note without a position sorts after+ /// every note with one rather than guessing a place among them. A sequence+ /// that *is* a chapter number carries no position — it is the key — so a+ /// site numbered by its URLs and a note the rule never matched stay on one+ /// scale, as they were before Q26. /// /// The comparison is total rather than a stable sort over a partial one — /// `sorted(by:)` makes no ordering promise for elements it considers equal,@@ -63,20 +75,35 @@ public final class WorkDetailModel { /// Two notes numbered and shared alike then keep the order they arrived in /// instead of moving between loads. private static func chapterOrder(_ rows: [WorkChapterRow]) -> [WorkChapterRow] {- rows.enumerated().sorted { left, right in- switch (left.element.chapterKey, right.element.chapterKey) {- case (.some(let leftKey), .some(let rightKey)) where leftKey != rightKey:- return leftKey < rightKey- // An unnumbered note sits below every numbered one.- case (.none, .some): return false+ rows.enumerated().sorted(by: precedesInChapterOrder).map(\.element)+ }++ private static func precedesInChapterOrder(+ _ left: (offset: Int, element: WorkChapterRow),+ _ right: (offset: Int, element: WorkChapterRow)+ ) -> Bool {+ let lhs = left.element+ let rhs = right.element+ // The URL's position where both have one; a note with one before a+ // note without; otherwise the chapter key on the same terms.+ switch (lhs.sequencePosition, rhs.sequencePosition) {+ case (.some(let leftPosition), .some(let rightPosition)):+ if leftPosition != rightPosition { return leftPosition < rightPosition }+ case (.some, .none): return true+ case (.none, .some): return false+ case (.none, .none):+ switch (lhs.chapterKey, rhs.chapterKey) {+ case (.some(let leftKey), .some(let rightKey)):+ if leftKey != rightKey { return leftKey < rightKey } case (.some, .none): return true- default:- if left.element.lastSharedAt != right.element.lastSharedAt {- return left.element.lastSharedAt < right.element.lastSharedAt- }- return left.offset < right.offset+ case (.none, .some): return false+ case (.none, .none): break }- }.map(\.element)+ }+ if lhs.lastSharedAt != rhs.lastSharedAt {+ return lhs.lastSharedAt < rhs.lastSharedAt+ }+ return left.offset < right.offset } // MARK: - Characters (`character-extraction` Reqs 3.2, 3.7, 5.1–5.3)
diff --git a/Asterism/AsterismTests/WorkDetailModelTests.swift b/Asterism/AsterismTests/WorkDetailModelTests.swiftindex f99a22f..ea280a5 100644--- a/Asterism/AsterismTests/WorkDetailModelTests.swift+++ b/Asterism/AsterismTests/WorkDetailModelTests.swift@@ -689,16 +689,21 @@ struct WorkDetailModelTests { #expect(mock.workDetailCallCount == 2) } - // MARK: - The spine's two orders (`work-detail-reading-redesign` Q4, Q13)+ // MARK: - The spine's two orders (`work-detail-reading-redesign` Q4, Q13, Q26) - /// A chapter row stated by the parts these cases are about: its key, when it- /// was shared, and a title that names it in the expectations.+ /// A chapter row stated by the parts these cases are about: its key, its+ /// position where its sequence is a site-wide id, when it was shared, and a+ /// title that names it in the expectations. private nonisolated static func row(- _ title: String, key: ChapterKey? = nil, sharedAt: Date+ _ title: String, key: ChapterKey? = nil, position: ChapterKey? = nil, sharedAt: Date ) -> WorkChapterRow { WorkChapterRow( id: UUID(), displayTitle: title, note: "", rating: nil, lastSharedAt: sharedAt,- chapterKey: key)+ chapterKey: key, sequencePosition: position)+ }++ private nonisolated static func position(_ value: Int) -> ChapterKey {+ ChapterKey(components: [value], label: String(value)) } private nonisolated static func key(_ components: [Int], _ label: String) -> ChapterKey {@@ -766,13 +771,88 @@ struct WorkDetailModelTests { == ["1", "2", "older interlude", "newer interlude"]) } + /// Q26: the URL's position orders the sequenced notes whether or not their+ /// titles carry a number, so an interlude sits between the chapters it was+ /// posted between rather than at the bottom.+ @MainActor @Test("Sequenced notes order by their URL position, numbered or not")+ func chapterOrderFollowsTheSequencePosition() async {+ let rows = [+ Self.row(+ "Chapter 12", key: Self.key([12], "12"), position: Self.position(3_012_346),+ sharedAt: TestFixtures.fixedDate),+ Self.row(+ "Interlude", position: Self.position(3_012_345), sharedAt: TestFixtures.laterDate),+ Self.row(+ "Chapter 11", key: Self.key([11], "11"), position: Self.position(3_012_344),+ sharedAt: TestFixtures.earlierDate),+ Self.row(+ "Chapter 13", key: Self.key([13], "13"), position: Self.position(3_012_347),+ sharedAt: TestFixtures.fixedDate),+ ]+ let (model, _, _) = makeSUT(detail: Self.detail(rows))++ await model.load()++ #expect(+ model.chapterRows(for: .chapter).map(\.displayTitle)+ == ["Chapter 11", "Interlude", "Chapter 12", "Chapter 13"])+ }++ /// Two notes at one position (a re-shared chapter, a duplicate capture)+ /// read oldest-first, as every other run does.+ @MainActor @Test("Notes sharing a position are ordered oldest-first")+ func chapterOrderBreaksPositionTiesByDate() async {+ let rows = [+ Self.row(+ "newer twin", position: Self.position(3_000_500), sharedAt: TestFixtures.laterDate),+ Self.row(+ "older twin", position: Self.position(3_000_500),+ sharedAt: TestFixtures.earlierDate),+ Self.row("next", position: Self.position(3_000_501), sharedAt: TestFixtures.fixedDate),+ ]+ let (model, _, _) = makeSUT(detail: Self.detail(rows))++ await model.load()++ #expect(+ model.chapterRows(for: .chapter).map(\.displayTitle)+ == ["older twin", "newer twin", "next"])+ }++ /// Q26: a position and a key are different scales, so a note the rule did+ /// not position sorts after every note it did, by its key among its kind.+ @MainActor @Test("Notes without a URL position follow the sequenced ones, by key")+ func chapterOrderTailsTheUnpositioned() async {+ let rows = [+ Self.row(+ "Chapter 2", key: Self.key([2], "2"), position: Self.position(3_012_345),+ sharedAt: TestFixtures.fixedDate),+ Self.row("Chapter 9", key: Self.key([9], "9"), sharedAt: TestFixtures.earlierDate),+ Self.row(+ "Chapter 1", key: Self.key([1], "1"), position: Self.position(3_012_344),+ sharedAt: TestFixtures.laterDate),+ Self.row("Author's note", sharedAt: TestFixtures.fixedDate),+ ]+ let (model, _, _) = makeSUT(detail: Self.detail(rows))++ await model.load()++ #expect(+ model.chapterRows(for: .chapter).map(\.displayTitle)+ == ["Chapter 1", "Chapter 2", "Chapter 9", "Author's note"])+ }+ /// Newest is not a sort at all — it is what the repository read returned. @MainActor @Test("The Newest order is the repository's order untouched") func newestOrderIsTheRepositoryOrder() async { let rows = [ Self.row("3", key: Self.key([3], "3"), sharedAt: TestFixtures.laterDate),- Self.row("1", key: Self.key([1], "1"), sharedAt: TestFixtures.fixedDate),- Self.row("interlude", sharedAt: TestFixtures.earlierDate),+ Self.row(+ "1", key: Self.key([1], "1"), position: Self.position(3_000_100),+ sharedAt: TestFixtures.fixedDate),+ Self.row(+ "interlude", position: Self.position(3_000_050),+ sharedAt: TestFixtures.earlierDate), ] let (model, _, _) = makeSUT(detail: Self.detail(rows))
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bbe5f24..e62401b 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Site-wide chapter ids order the spine but never label it+ (work-detail-reading-redesign, Q25–Q26).** On Royal Road the URL rule's chapter+ sequence is the site-wide chapter id, so the spine showed seven-digit "chapter+ numbers". A number of 10 000 or more is now read as an identifier, not a+ chapter: the gutter shows the title's number (`Chapter 12: …` → 12) or nothing.+ A sequence past that limit still *orders* the Chapter view, so an interlude+ with no number in its title sits between the chapters it was posted between+ instead of at the bottom; notes with a chapter number follow, by that number.+ Sites whose URL sequence is the chapter number are unchanged. Teaching the rule whether its sequence is the chapter number is+ T-2270.+ - **Work detail redesign — documentation and verification (work-detail-reading-redesign, phase 4: Docs and verification).** `docs/asterism-style-guide.md` replaces the rating-pulse recipe with the meta line and adds the selected cast pill, the
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift b/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swiftindex 480a769..bb3ab16 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift@@ -51,18 +51,54 @@ nonisolated enum ChapterKeyDerivation { /// that follows it. private static let separators: Set<Character> = [":", "-", "\u{2013}", "\u{2014}", "."] - /// The note's key: the URL rule's sequence first (Q1), the title only behind- /// a chapter prefix (Decision 2), nil otherwise.+ /// The largest number that is read as a chapter number (Q25). A URL rule's+ /// sequence is the component that *distinguishes* chapters, not always the+ /// one that counts them: Royal Road's is a site-wide chapter id in the+ /// millions. No serial has ten thousand chapters, so a run at or past this+ /// is an identifier, and the group it sits in yields no key — the title+ /// rule gets its turn, or the note stays unnumbered.+ static let largestChapterNumber = 9_999++ /// The note's key: the URL rule's sequence where it is a chapter number+ /// (Q1, Q25), else the title behind a chapter prefix (Decision 2), nil+ /// otherwise. A sequence past the limit is an identifier — the title rule+ /// gets its turn, and `position(chapterSequence:)` carries the id. static func key(chapterSequence: String?, chapterTitle: String?) -> ChapterKey? {- if let sequence = chapterSequence, let group = firstNumberGroup(in: Array(sequence)) {+ if let sequence = chapterSequence, let group = firstNumberGroup(in: Array(sequence)),+ isChapterNumber(group.key)+ { return group.key }- if let title = chapterTitle, let match = prefixedNumberGroup(in: Array(title)) {+ if let title = chapterTitle, let match = prefixedNumberGroup(in: Array(title)),+ isChapterNumber(match.key)+ { return match.key } return nil } + /// Where the note sits in its work by the URL rule's sequence, where that+ /// sequence is an identifier rather than a chapter number (Q26). Never+ /// shown: this orders the spine, the key labels it. A site-wide id is a+ /// useless label and a reliable position — it grows with publication, so an+ /// interlude with no number in its title still sorts between the chapters+ /// it was posted between. Nil where the rule produced no sequence, where+ /// the sequence has no number that fits `Int`, and where the number is a+ /// chapter number: the key carries it then, so a site numbered by its URLs+ /// and a note the rule never matched still order on one scale.+ static func position(chapterSequence: String?) -> ChapterKey? {+ guard let sequence = chapterSequence, let group = firstNumberGroup(in: Array(sequence)),+ !isChapterNumber(group.key)+ else { return nil }+ return group.key+ }++ /// Q25: every part at or under the limit. A group with a part past it is+ /// an identifier as a whole; no part of it is drawn.+ private static func isChapterNumber(_ key: ChapterKey) -> Bool {+ key.components.allSatisfy { $0 <= largestChapterNumber }+ }+ /// What the spine row calls the note, or nil where the gutter already says /// it (Q5). The caller keeps `displayTitle` for entry detail and for the /// accessibility label either way.@@ -99,7 +135,8 @@ nonisolated enum ChapterKeyDerivation { /// One or more runs of ASCII digits joined by single `.` or `-` separators, /// parsed at `start`. Nil where no digit starts there, or where a run does- /// not fit `Int`.+ /// not fit `Int`. The chapter-number limit is not applied here — `key` and+ /// `position` decide what a group over it means. private static func numberGroup( _ characters: [Character], from start: Int ) -> (key: ChapterKey, end: Int)? {@@ -124,7 +161,8 @@ nonisolated enum ChapterKeyDerivation { return (ChapterKey(components: components, label: written.joined(separator: ".")), index) } - /// The first number group anywhere in the characters.+ /// The first number group anywhere in the characters — only the first; a+ /// later, smaller one is never tried in its place. private static func firstNumberGroup(in characters: [Character]) -> (key: ChapterKey, end: Int)? { guard let start = characters.firstIndex(where: isASCIIDigit) else { return nil }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swiftindex eae1433..ce0a19b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift@@ -35,6 +35,14 @@ public struct WorkChapterRow: Identifiable, Sendable, Equatable { /// order (Decision 2, Q15). Nil where neither the URL rule's sequence nor a /// prefixed title numbers it. public let chapterKey: ChapterKey?+ /// Where the note sits by the URL rule's sequence, where that sequence is+ /// an identifier rather than a chapter number (Q26). Orders the Chapter+ /// view and is never drawn: on Royal Road it is the site-wide chapter id,+ /// which places an interlude between the chapters it was posted between+ /// even though its title has no number. Nil where the rule produced no+ /// sequence, and where the sequence is a chapter number — `chapterKey`+ /// carries it then.+ public let sequencePosition: ChapterKey? /// What the spine row calls the note (Q5). Nil where the title is only the /// key's own number, which the gutter already draws. Derived here from the /// title and the key rather than passed in (Q16), so a row built by hand —@@ -44,7 +52,7 @@ public struct WorkChapterRow: Identifiable, Sendable, Equatable { public init( id: UUID, displayTitle: String, note: String, rating: Rating?, lastSharedAt: Date,- chapterKey: ChapterKey? = nil+ chapterKey: ChapterKey? = nil, sequencePosition: ChapterKey? = nil ) { self.id = id self.displayTitle = displayTitle@@ -52,6 +60,7 @@ public struct WorkChapterRow: Identifiable, Sendable, Equatable { self.rating = rating self.lastSharedAt = lastSharedAt self.chapterKey = chapterKey+ self.sequencePosition = sequencePosition self.rowTitle = ChapterKeyDerivation.rowTitle(displayTitle: displayTitle, key: chapterKey) } }@@ -137,7 +146,9 @@ extension LibraryRepository { note: entry.note, rating: entry.rating, lastSharedAt: entry.lastSharedAt,- chapterKey: chapterKey)+ chapterKey: chapterKey,+ sequencePosition: ChapterKeyDerivation.position(+ chapterSequence: entry.chapterSequence)) } // Q88's two inputs, from this same context: where each live entry
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swiftindex 48061cd..00b955c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swift@@ -58,6 +58,47 @@ struct ChapterKeyDerivationTests { #expect(Self.key(title: "Chapter 99999999999999999999") == nil) } + /// Q25: a URL rule's sequence distinguishes chapters; it does not always+ /// count them. Royal Road's is a site-wide id in the millions, and a number+ /// that large is never a chapter — the title rule gets its turn instead.+ @Test("A number of 10 000 or more is an identifier, not a chapter")+ func largeNumbersAreNotChapters() {+ #expect(Self.key(sequence: "3012345") == nil)+ #expect(Self.key(sequence: "10000") == nil)+ #expect(Self.key(sequence: "9999") == Self.key([9999], "9999"))+ #expect(Self.key(title: "Chapter 10000") == nil)+ #expect(Self.key(title: "Chapter 9999") == Self.key([9999], "9999"))+ // A part past the limit spoils the whole group.+ #expect(Self.key(sequence: "5-10000") == nil)+ // Royal Road's shape: the id in the URL, the number in the title.+ #expect(+ Self.key(sequence: "3012345", title: "Chapter 12: The Long Way")+ == Self.key([12], "12"))+ // …and an unnumbered title on such a site stays unnumbered.+ #expect(Self.key(sequence: "3012345", title: "Interlude \u{2013} Pisces") == nil)+ }++ /// Q26: a sequence past the limit is a position; the key labels the+ /// gutter, the position orders the spine. A sequence within the limit is+ /// the key's to carry and gives no position, so a site numbered by its+ /// URLs keeps one scale with any note the rule never matched.+ @Test("A sequence is a position only where it is an identifier, not a chapter number")+ func sequencesArePositionsOnlyAsIdentifiers() {+ #expect(+ ChapterKeyDerivation.position(chapterSequence: "3012345")+ == Self.key([3_012_345], "3012345"))+ #expect(+ ChapterKeyDerivation.position(chapterSequence: "10000") == Self.key([10000], "10000"))+ #expect(+ ChapterKeyDerivation.position(chapterSequence: "5-10000")+ == Self.key([5, 10000], "5.10000"))+ #expect(ChapterKeyDerivation.position(chapterSequence: "5-07") == nil)+ #expect(ChapterKeyDerivation.position(chapterSequence: "9999") == nil)+ #expect(ChapterKeyDerivation.position(chapterSequence: nil) == nil)+ #expect(ChapterKeyDerivation.position(chapterSequence: "finale") == nil)+ #expect(ChapterKeyDerivation.position(chapterSequence: "99999999999999999999") == nil)+ }+ /// Only `0`–`9`: a site numbering in other digits is left unnumbered rather /// than parsed by a rule nobody taught. @Test("Non-ASCII digits yield no key")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swiftindex 3fccad3..a425f1a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift@@ -161,6 +161,9 @@ struct WorkDetailReadTests { let titledChapter = UUID() let trimmedAgainstSequence = UUID() let unnumbered = UUID()+ let siteWideID = UUID()+ let siteWideInterlude = UUID()+ let siteWideUntitled = UUID() try await fixture.repository.seedM5Rows( sites: [M5SeedSite(hostname: "example.com")], works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],@@ -181,6 +184,20 @@ struct WorkDetailReadTests { M5SeedEntry( id: unnumbered, captureTitle: "A Serial", hostname: "example.com", path: "four", chapterTitle: "Interlude \u{2013} Pisces", workID: workID),+ // Royal Road's shape (Q25, Q26): the sequence is a site-wide id+ // — a position, never a label — and the title carries the number.+ M5SeedEntry(+ id: siteWideID, captureTitle: "A Serial", hostname: "example.com",+ path: "five", chapterTitle: "Chapter 3 \u{2013} Good Morning",+ chapterSequence: "3012345", workID: workID),+ M5SeedEntry(+ id: siteWideInterlude, captureTitle: "A Serial", hostname: "example.com",+ path: "six", chapterTitle: "Interlude \u{2013} Zorian",+ chapterSequence: "3012346", workID: workID),+ // The same site with no title rule: the id is all there is.+ M5SeedEntry(+ id: siteWideUntitled, captureTitle: "A Serial", hostname: "example.com",+ path: "seven", chapterSequence: "3012347", workID: workID), ]) let detail = try await fixture.repository.workDetail(id: workID)@@ -200,6 +217,32 @@ struct WorkDetailReadTests { #expect(byEntry[unnumbered]?.chapterKey == nil) #expect(byEntry[unnumbered]?.rowTitle == "Interlude \u{2013} Pisces")++ // A sequence that is a chapter number is the key's to carry; only an+ // identifier past the limit becomes a position.+ #expect(byEntry[sequenceOnly]?.sequencePosition == nil)+ #expect(byEntry[trimmedAgainstSequence]?.sequencePosition == nil)+ #expect(byEntry[titledChapter]?.sequencePosition == nil)+ #expect(byEntry[unnumbered]?.sequencePosition == nil)+ #expect(byEntry[siteWideID]?.chapterKey == ChapterKey(components: [3], label: "3"))+ #expect(byEntry[siteWideID]?.rowTitle == "Good Morning")+ #expect(+ byEntry[siteWideID]?.sequencePosition+ == ChapterKey(components: [3_012_345], label: "3012345"))+ #expect(byEntry[siteWideInterlude]?.chapterKey == nil)+ #expect(byEntry[siteWideInterlude]?.rowTitle == "Interlude \u{2013} Zorian")+ #expect(+ byEntry[siteWideInterlude]?.sequencePosition+ == ChapterKey(components: [3_012_346], label: "3012346"))+ // With no chapter title, Req 8.12's fallback still names the note by+ // its sequence, so the id stays as the title even though the gutter+ // withholds it — the gap T-2270 closes. Pinned so a change is a choice.+ #expect(byEntry[siteWideUntitled]?.displayTitle == "3012347")+ #expect(byEntry[siteWideUntitled]?.rowTitle == "3012347")+ #expect(byEntry[siteWideUntitled]?.chapterKey == nil)+ #expect(+ byEntry[siteWideUntitled]?.sequencePosition+ == ChapterKey(components: [3_012_347], label: "3012347")) // `displayTitle` is untouched throughout: entry detail and the row's // accessibility label still name the note in full. #expect(byEntry[trimmedAgainstSequence]?.displayTitle == "Chapter 007 \u{2013} The Fall")
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex 4bef688..405b710 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -314,7 +314,7 @@ View mode is a reading surface, not a summary: the notes are the content and the 2. **Notes on this work** — generic free-form notes as a plain paragraph under the tags, where non-empty. The lede of the page; it has no header and no card of its own, because an empty field on a read screen invites an edit the screen is not offering. 3. **Open last noted chapter** — primary action, and the screen's one gradient button. Labelled precisely: it opens the newest entry's URL ("back to where I was"), not the latest published chapter. 4. **Characters** — the work's cast as pills. Tapping one expands an inline card beneath the row (not a sheet): the serif name with its aliases as chips, the character note, then each extracted fact on a gutter. A fact whose cited note still exists carries that note's chapter number in the gutter as a link that opens it; a fact from a note with no number gets an arrow and keeps its caption; a fact from the work's own notes or from a note that is gone shows a dash and says which. Full behaviour in `specs/character-extraction/`.-5. **Chapter notes** — the **spine**: one row per entry, no card and no clamp. A gutter carries the entry's rating dot over its chapter number, threaded by a rail that runs the length of the list; beside it the chapter title with its date, and then the whole note. Two orders, chosen by a two-segment capsule in the section header and reset to Newest on every open: **Newest** is lastSharedAt descending (§7), **Chapter** puts numbered notes in chapter-key order — ties oldest-first — and the unnumbered ones after them, oldest-first. **Tapping a row opens that entry's detail screen** (Q56).+5. **Chapter notes** — the **spine**: one row per entry, no card and no clamp. A gutter carries the entry's rating dot over its chapter number, threaded by a rail that runs the length of the list; beside it the chapter title with its date, and then the whole note. Two orders, chosen by a two-segment capsule in the section header and reset to Newest on every open: **Newest** is lastSharedAt descending (§7), **Chapter** orders by the URL rule's sequence where that sequence is a site-wide id — so a Royal Road interlude sits between the chapters it was posted between even with no number in its title — then the notes with a chapter number (from a title, or from a sequence that is one) by that number, then the rest; every run oldest-first on a tie (Q4, Q26). **Tapping a row opens that entry's detail screen** (Q56). Its toolbar carries **Export** (the one read action) and the pencil. @@ -346,7 +346,7 @@ Actions: Within a work and in the feed, entries order by lastSharedAt. Work pages display newest-first and offer chapter order as the alternative (§6); exports render oldest-first (§8). -**Chapter numbers are derived, and titles contribute one only behind a chapter prefix** (`specs/work-detail-reading-redesign/`, Decision 2). A note's key comes from the URL rule's `chapterSequence` first, because that is the taught, site-specific source. Failing that, it comes from the *title* only when the title starts with `chapter`, `ch.`, `ch`, `episode`, `ep.`, `ep` or `part` as a whole word followed by a number — a title that opens that way is the site stating its own number. Any other title yields no key, so "Book 2, Chapter 14", "Top 10 Moments" and "Interlude – Pisces" stay unnumbered rather than mis-key on the first digit run they contain. A key is a *multi-part* number compared component-wise (`5.07`, `5-07` and `1.01` for volume.chapter numbering; `12 < 12.5 < 13`), so the cases the old blanket refusal named — decimals, interludes, rewrites — either sort into their real place or take no key at all. Nothing is persisted: the key is derived on read from what the rules produced, so re-teaching a site renumbers the spine with no migration, and unnumbered notes sort last in chapter order.+**Chapter numbers are derived, and titles contribute one only behind a chapter prefix** (`specs/work-detail-reading-redesign/`, Decision 2). A note's key comes from the URL rule's `chapterSequence` first, because that is the taught, site-specific source. Failing that, it comes from the *title* only when the title starts with `chapter`, `ch.`, `ch`, `episode`, `ep.`, `ep` or `part` as a whole word followed by a number — a title that opens that way is the site stating its own number. Any other title yields no key, so "Book 2, Chapter 14", "Top 10 Moments" and "Interlude – Pisces" stay unnumbered rather than mis-key on the first digit run they contain. A key is a *multi-part* number compared component-wise (`5.07`, `5-07` and `1.01` for volume.chapter numbering; `12 < 12.5 < 13`), so the cases the old blanket refusal named — decimals, interludes, rewrites — either sort into their real place or take no key at all. Nothing is persisted: the key is derived on read from what the rules produced, so re-teaching a site renumbers the spine with no migration, and unnumbered notes sort last in chapter order. A number of 10 000 or more is never read as a chapter (Q25 there): a URL rule's sequence is the component that *distinguishes* chapters and on some sites (Royal Road) it is a site-wide id, so such a sequence yields no key and the title rule gets its turn. A sequence past that limit still *orders* the spine (`sequencePosition`, Q26): a site-wide id is a useless label and a reliable position. A sequence within it is the key and orders as one. Teaching the rule whether its sequence is the chapter number is T-2270. ---
diff --git a/docs/asterism-style-guide.md b/docs/asterism-style-guide.mdindex ca05a35..26df75d 100644--- a/docs/asterism-style-guide.md+++ b/docs/asterism-style-guide.md@@ -96,7 +96,7 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field - **Unattached notes group**: dashed border (`rgba(170,200,255,.18)` dark), lower fill, dim ✎ glyph, neutral count pill. *[Works dark]*. - **Meta line** (work detail): `{n} notes ▲ {up} ▼ {down}` on one `.caption` line beside the site identity — notes count in primary text, semibold; ▲ and its count cyan; ▼ and its count violet. It replaces the three equal pulse cards this section used to name (`specs/work-detail-reading-redesign/`): the counts are metadata, and three cards made them the page. It wraps rather than truncates — a truncated count is a wrong count. - **Selected pill**: a pill that has been opened — work detail's cast pill, with its chevron pointing up — takes the type-tag recipe lifted: violet .26 fill, violet .6 border, no glow (`ConstellationPillKind.selectedTypeTag`, Q11/Q14). Selection changes no other pill.-- **Spine row** (work detail's chapter notes): no card. A gutter (≥ 44 pt, growing with its label) holds a 10 pt rating dot — cyan filled for ▲, violet for ▼, a 1.5 pt dim ring when unrated — over the chapter number in SF Mono dim, and a 1 pt `cardBorder` rail runs through the dots from the first row's to the last row's. Beside it: the row title (13 pt semibold, one line, truncating) with the date trailing in `.caption2` dim, then the whole note at 15 pt with no line limit. The rail is drawn in the row container's background, outside the row's button, so pressing a row does not dim it.+- **Spine row** (work detail's chapter notes): no card. A gutter (≥ 44 pt, growing with its label) holds a 10 pt rating dot — cyan filled for ▲, violet for ▼, a 1.5 pt dim ring when unrated — over the chapter number in SF Mono dim (nothing beneath the dot where the note has no number — an interlude, or a site whose URL carries only an id, Q25), and a 1 pt `cardBorder` rail runs through the dots from the first row's to the last row's. Beside it: the row title (13 pt semibold, one line, truncating) with the date trailing in `.caption2` dim, then the whole note at 15 pt with no line limit. The rail is drawn in the row container's background, outside the row's button, so pressing a row does not dim it. - **Two-segment capsule, or a menu**: use a **capsule** when a choice has two short labels and both must stay visible, so the reader sees the state instead of opening something to read it — work detail's `Newest | Chapter` sort (`specs/work-detail-reading-redesign/`, Decision 1). Recipe: `cardFill` fill with a 1 pt `cardBorder`; selected segment cyan .14 fill, cyan .4 border, cyan text; unselected dim; `.caption` semibold; a 32 pt visual (scaled with Dynamic Type via `@ScaledMetric`, so the segment grows with its label) inside a 44 pt target. It sits beside its section header, drops to its own line beneath the header where the two do not fit, and at the largest accessibility sizes stacks its segments full width in a card-radius rectangle rather than hyphenate a label (Q21). Use a **`Menu`** instead when the choice has more than two options or long ones, where no segmented shape fits at the accessibility sizes — Stats' five periods (`specs/stats-page/`, Q31). - **Provenance disclosure**: quieter-than-card fill (.035), SF Mono 11 pt, dim, labels slightly brighter. Collapsed by default. *(No longer used on entry detail — the block was removed from that screen entirely, `specs/polish-and-export/` Q52. The recipe stands for any diagnostics surface that needs it.)*
diff --git a/specs/work-detail-reading-redesign/decision_log.md b/specs/work-detail-reading-redesign/decision_log.mdindex 6250dac..5e1bd86 100644--- a/specs/work-detail-reading-redesign/decision_log.md+++ b/specs/work-detail-reading-redesign/decision_log.md@@ -28,6 +28,8 @@ | Q22 | 2026-08-23 | The character card's hairline is drawn only when the character has facts | A rule with nothing under it is a stray line; the smolspec listed the hairline in the card's order without saying what it divides when there are no facts. Pre-push review | | Q23 | 2026-08-23 | The Chapter order's final tie-break is the row's position in the repository order | Q4 names key then `lastSharedAt`; Swift's `sorted(by:)` makes no stability promise, so two notes numbered and shared alike would otherwise move between loads. Pre-push review | | Q24 | 2026-08-23 | The rail-join check the smolspec's Risks section asks for was done in the simulator at the default and accessibility-XXXL text sizes; the segments meet with no gap at either | Recorded here rather than in `tasks.md`, which must stay rune-parseable. The first capture exposed a `GeometryReader` sibling claiming the row width, fixed before the View phase was marked done. Pre-push review |+| Q25 | 2026-08-23 | A number of 10 000 or more is never a chapter key; a group containing one yields no key, so the title rule gets its turn | A URL rule's `chapterSequence` is the component that distinguishes chapters, not always the one that counts them — Royal Road's is a site-wide chapter id in the millions, and the spine showed it as the chapter number. No serial has ten thousand chapters. A quick fix; the proper one is to teach the rule whether its sequence is the number (T-2270). Supersedes the smolspec's assumption that the sequence is the chapter number on every taught site |+| Q26 | 2026-08-23 | `WorkChapterRow.sequencePosition` carries the URL rule's sequence where it is an identifier (past the Q25 limit), for ordering only; Chapter order runs those notes first by id, then notes with a chapter key by key, then the rest — each run oldest-first on a tie | A site-wide id is a useless label but a reliable position: it grows with publication, so an interlude with no number in its title sorts between the chapters it was posted between. The two scales cannot interleave, so a note without an id sorts after every note with one rather than being guessed among them. A sequence that is a chapter number gives no position — it is the key — so a site numbered by its URLs and a note the rule never matched stay on one scale, as before. Refines Q4; the gutter still shows only the key (Q25). A chapter deleted and re-posted takes a newer id and sorts last — judged in practice, like Decision 2's title keys | ## Decision 1: Sort Control Is a Capsule That Wraps Below the Header
diff --git a/specs/work-detail-reading-redesign/implementation.md b/specs/work-detail-reading-redesign/implementation.mdindex 11419b0..71c23df 100644--- a/specs/work-detail-reading-redesign/implementation.md+++ b/specs/work-detail-reading-redesign/implementation.md@@ -40,7 +40,12 @@ continuous read and jump to any chapter. that starts with "Chapter 14", "Ch. 3", "Episode 2" or "Part 4". Titles like "Book 2, Chapter 14" or "Top 10 Moments" deliberately get no number, because guessing would put notes in the wrong order. Nothing is saved: the- number is worked out fresh each time the page loads.+ number is worked out fresh each time the page loads. A number of 10 000 or+ more is never shown as a chapter — on Royal Road the web address carries a+ site-wide id in the millions, which is not a chapter number (Q25).+- **Position**: a web-address number that is an id rather than a chapter+ number, used only to put notes in order (Q26). An interlude with no number in its title still lands+ between the chapters it was posted between; its gutter just stays empty. - **Spine**: the vertical rail with dots, like the spine of a book holding the pages in order. - **Gutter**: the narrow column on the left that holds the dot and number@@ -55,7 +60,7 @@ continuous read and jump to any chapter. | Layer | File | Change | |---|---|---| | Core projection | `Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift` (new) | `ChapterKey` (`Comparable`, `[Int]` components + `label`) and the pure `ChapterKeyDerivation` helper: `key(chapterSequence:chapterTitle:)`, `rowTitle(displayTitle:key:)`. |-| Core projection | `LibraryRepository+WorkDetail.swift` | `WorkChapterRow.chapterKey` derived where rows are built; `rowTitle` derived in `init` from `displayTitle` + `chapterKey` (Q16); a `[UUID: ChapterKey]` map passed into `characterPresentations`. |+| Core projection | `LibraryRepository+WorkDetail.swift` | `WorkChapterRow.chapterKey` derived where rows are built; `rowTitle` derived in `init` from `displayTitle` + `chapterKey` (Q16); `sequencePosition` — the sequence where it is an id, ordering only (Q26); a `[UUID: ChapterKey]` map passed into `characterPresentations`. | | Core projection | `WorkCharacterPresentation.swift` | `WorkCharacterFactRow.citedChapterKey`, populated only for live citations. | | Kit | `ConstellationRecipes.swift` | `ConstellationPillKind.selectedTypeTag` (violet .26 fill / .6 border, no glow). | | Model | `WorkDetailModel.swift` | `ChapterSortOrder { newest, chapter }`; a private `chapterRowsByChapter` assigned in the same statement as `presentation` in `load()`; `chapterRows(for:)`. |@@ -77,7 +82,15 @@ continuous read and jump to any chapter. - **Two orders, one moment (Q13).** `load()` assigns `(presentation, chapterRowsByChapter)` in one tuple statement so the Chapter order is never a second snapshot, and sorting never runs in `body`.- The comparator is total: key `<`, then `lastSharedAt`, then original index.+ The comparator is total and three-tiered (Q26): notes with a+ `sequencePosition` by that position, then key-only notes by key, then the+ rest — each tier by `lastSharedAt`, then original index.+- **Label and position are different jobs (Q25, Q26).** `ChapterKey` labels+ the gutter and is withheld for any number ≥ 10 000 (a site-wide id is not a+ chapter number); `sequencePosition` is the sequence only where it is an id,+ and only orders — a sequence that is a chapter number is the key and orders+ as one. A Royal Road interlude is positioned by its id and shows no number;+ every other site sorts exactly as before. - **Rail outside the button.** Each spine row draws its own 1 pt rail segment in the row container's `.background` with `allowsHitTesting(false)`, so pressing a note doesn't dim the line and the rail doesn't widen the tap@@ -193,7 +206,11 @@ superseded documents updated). **Implemented with a documented deviation** (all in the decision log): `rowTitle` derived in `init` (Q16); header/meta-line wrapping (Q18); fact gutter 44 pt throughout (Q19); dash as `Text` (Q20); capsule stacks at XXXL-(Q21).+(Q21); a number ≥ 10 000 is never a key (Q25) and an id-scale sequence orders+the spine through `sequencePosition` (Q26) — the smolspec's+assumption that the sequence is the chapter number on every taught site was+wrong for Royal Road, where it is a site-wide id. Teaching the rule what its+sequence is remains T-2270. **Partially implemented / missing**: none found. The manual rail-join check the Risks section asks for was performed in the simulator at the default and
diff --git a/specs/work-detail-reading-redesign/smolspec.md b/specs/work-detail-reading-redesign/smolspec.mdindex 41e8e46..28d6f19 100644--- a/specs/work-detail-reading-redesign/smolspec.md+++ b/specs/work-detail-reading-redesign/smolspec.md@@ -50,7 +50,7 @@ General - Risk: a `List` row with a zero vertical inset and a row background could still show a hairline gap between segments on some scale factors | Mitigation: the segment overdraws by 0.5 pt at each end; the join is inspected in the simulator at the default and the largest accessibility text size before the spine task is marked done (a manual check — there are no snapshot tests in this repo). - Risk: title trimming removes a number that is not a chapter ("Part 2" of a two-part chapter) | Mitigation: trimming fires only when the number equals the derived key, and `displayTitle` still names entry detail. - Risk: a title-derived key is wrong (a title that starts "Part 2" for the second half of one chapter keys 2) and mis-sorts the spine in Chapter order | Mitigation: only titles that start with a chapter prefix key (Decision 2), the mis-key affects only Chapter order and the gutter, never Newest, and the user has chosen to judge the rule in practice.-- Assumption: `chapterSequence` digit runs are the chapter number on every taught site; a non-numeric sequence yields an unnumbered note, which is acceptable.+- ~~Assumption: `chapterSequence` digit runs are the chapter number on every taught site; a non-numeric sequence yields an unnumbered note, which is acceptable.~~ **Superseded by Q25–Q26** (2026-08-23): on Royal Road the sequence is the site-wide chapter id. A number of 10 000 or more is never a key (the gutter shows the title's number or nothing), and a sequence past that limit still orders the spine through `sequencePosition`. Teaching the rule what its sequence is remains T-2270. - Assumption: the pulse-anchored UI tests need only the identifiers and labels, not card geometry (`AccessibilityJourneyUITests.swift:323` queries `.any`). - Prerequisite: none beyond the current `main`.
The user had no Royal Road case to hand; the device build (1083e03) showed everything else unchanged. The unit tests model the shape from the repo's own URL corpus fixture (/chapter/500001/chapter-1).
One make test-quick run failed a 30 ms-sleep suggestion test unrelated to this change; green on re-run and on the final tree.
The app target compiled against the old package module after the init change; cleared per the testing note. Clean CI is unaffected.