The work detail screen rebuilt around its notes: a folded header, a chapter-note spine with derived chapter keys and a Newest/Chapter order, and an inline character card whose facts link to their chapters. Four parallel reviewers (reuse, quality, efficiency, spec/docs/tests); fixes applied and committed as e56dc7d.
ChapterKey comes from the URL rule's sequence, else a title behind a chapter prefix (Decision 2); multi-part keys sort component-wise (Q15). No schema change.load() assigns presentation and the Chapter order in one statement (Q13); the comparator is total (Q23).ViewThatFits fallbacks — meta line (Q18), sort capsule under the header (Decision 1), segments stacked at XXXL (Q21).rowTitle derived in init (Q16), one fact-gutter width (Q19), fact row as an accessibility container, citation UI test, four doc corrections.Ready to push
Every MUST in the smolspec is implemented and covered; the one major finding (the citation gutter had no test and its identifier was shadowed) is fixed with a UI test that drives it end to end. After the fixes make test-core, make test-quick and CharacterExtractionUITests (9/9) pass; the other five work-detail UI suites passed on the phase-4 verification and the review changes do not touch what they anchor on. Skipped findings are nits or deliberate spec choices, each recorded.
e56dc7d [feat]: pre-push review fixes for the work-detail reading redesign 50f0ed7 [doc]: changelog for phase 4 (Docs and verification) a1f156d [doc]: Describe the work-detail reading redesign as shipped 9411885 [doc]: changelog for phase 3 (View) 9b8d122 [feat]: stack the spine's sort segments where they cannot share a line 4ba9f87 [feat]: Work detail reads as its notes — folded header, spine, fact gutter c2268b2 [doc]: changelog for phase 2 (Model) a0832d6 [feat]: Work detail model exposes the Newest and Chapter orders 895b379 [doc]: changelog for phase 1 (Core projection) 3827cb6 [feat]: derive a chapter key and row title for the work-detail spine edd8527 Spec: work-detail-reading-redesign smolspec, decisions and tasks The story page used to be a stack of boxes with each chapter note cut to two lines. Now the page is the notes: a plain title and site line, a small 2 notes ▲ 1 ▼ 0 line, your notes about the story as a paragraph, and then every chapter note in full along a vertical "spine" — a thin line with a dot per note (blue-green ▲, purple ▼, hollow unrated) and the chapter number under the dot. A Newest | Chapter switch changes the order; it resets to Newest each time you open the page. Character cards show other names as chips and, beside each fact, the chapter it came from — tap it to open that note.
A reading journal is for re-reading what you wrote. You can now scroll your notes as one read and jump to any chapter.
ChapterKeyDerivation.swift (new, core): ChapterKey (Comparable, [Int] components + label) and a pure, nonisolated helper with key(chapterSequence:chapterTitle:) and rowTitle(displayTitle:key:).LibraryRepository+WorkDetail.swift: WorkChapterRow.chapterKey derived where rows are built; rowTitle derived in init; a [UUID: ChapterKey] map feeds WorkCharacterFactRow.citedChapterKey.ConstellationRecipes.swift: ConstellationPillKind.selectedTypeTag.WorkDetailModel.swift: ChapterSortOrder, a private Chapter order assigned with presentation, chapterRows(for:).WorkDetailView.swift: folded header with a FlowLayout meta line, WorkChapterSpineRow/WorkChapterRowView, the sort capsule, the gutter-based character card../-) in the sequence; else a title behind a whole-word prefix. Non-ASCII digits and Int overflow yield no key..background with allowsHitTesting(false); 0.5 pt overdraw at each join; List kept for recycling (Q2)..count; one sortSurface(_:) for both capsule shapes.rowTitle in init costs a second scan of a prefixed title (~1–2 ms at 500 rows) in exchange for the key and title never coming apart (Q16).ChapterKey.< compares components pairwise, then by count (shorter prefix first), then by label, giving a strict total order consistent with synthesised ==; 007 and 7 are distinct, so Q4's lastSharedAt tie-break fires only on identical keys (Q17, now tested). The prefix guard rejects a following letter only: Chapters 14/Party 3 key nothing, Chapter14 keys 14. Separators continue a group only when a digit follows, and a title separator is stripped only when whitespace or end follows it. An empty remainder keeps displayTitle whole — "Chapter 14" beside gutter "14" is Q5, not a defect.
chapterOrder sorts enumerated() with the offset as final tie-break because sorted(by:) promises no stability (Q23). The rail re-renders the gutter hidden as a width sizer and overlays a GeometryReader drawing from isFirst ? dotCentreOffset : -0.5 to isLast ? dotCentreOffset : height + 0.5; a single row draws a zero-height line. A PreferenceKey would avoid the duplicate subtree at the cost of a second layout pass — not worth it for ~12 visible rows.
WorkChapterRow.init now calls the derivation — future key-dependent fields should follow that pattern, not add defaulted parameters.characterPresentations/factRows thread a fourth per-entry side table; a fifth justifies a CitedNote struct.Menu, this capsule); §7 names when each applies.isDateInToday evaluated per row per body: a page open across midnight says "Today" until the next evaluation.frame.minY; sticky section headers would change which element is "above".Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift
Why it matters. Every Chapter-order position and every gutter number comes from this 190-line pure helper; a wrong rule reorders the spine.
What to look at. ChapterKeyDerivation.swift:17-187 — ChapterKey.<, key(chapterSequence:chapterTitle:), rowTitle(displayTitle:key:), numberGroup, matching
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift
Why it matters. Public projection API; the same key feeds the spine and the character facts so the two gutters cannot disagree.
What to look at. LibraryRepository+WorkDetail.swift:31-56 (init), :124-142 (row build), :166-175 (keys map)
Asterism/Asterism/ViewModels/WorkDetailModel.swift
Why it matters. The only per-load work added; its atomicity is what keeps the Chapter order from describing a different snapshot than the screen.
What to look at. WorkDetailModel.swift:33-86 (ChapterSortOrder, chapterRows(for:), chapterOrder), :195 (tuple assignment)
Asterism/Asterism/Views/WorkDetailView.swift
Why it matters. The user-visible core of the change and the trickiest geometry: the rail must run through the dots, meet at row joins, and not be part of the button.
What to look at. WorkDetailView.swift — WorkChapterSpineRow (rail, accessibility label/value) and WorkChapterRowView (gutter, dotCentreOffset)
Asterism/Asterism/Views/WorkDetailView.swift
Why it matters. A new control shape for the app; had to satisfy §10 (no clipping) at every Dynamic Type size.
What to look at. WorkDetailView.swift — chapterSectionHeader, sortControl, sortSurface(_:), sortSegments(fillsWidth:), sortSegment
Asterism/AsterismUITests/CharacterExtractionUITests.swift
Why it matters. The one new interactive control had no coverage and its identifier was shadowed — the test would have been impossible to write without the view fix.
Two short labels that must both stay visible; Stats' five long labels forced a Menu there. Wraps under the header, and (Q21) stacks its segments at XXXL.
Sequence first; chapter/ch./ch/episode/ep./ep/part as a whole word. Replaces the design doc's blanket "no chapter-number parsing".
Testable without SwiftUI, no per-evaluation sort, one moment for both orders.
Revised at review from a nil default; the key and the title it is trimmed against cannot come apart.
Total order consistent with ==; 007 and 7 are distinct keys.
Revised at review: a 34 pt dash row beside a 44 pt button row staggered the statements in a card.
Added at review for divergences the spec reviewer found undocumented.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | Testing — citation gutter | The citation button in the fact gutter, the one new interactive control, had no test; its identifier was also shadowed by the container's `work-detail-character-fact`. | Fact row is now an accessibility container (`children: .contain`); new UI test `testACitationInTheFactGutterOpensTheCitedNote` taps the citation through to entry detail and asserts the alias chip exists. |
| minor | WorkDetailView.swift — fact gutter width | Live citation rows used a 44 pt gutter, dash rows 34 pt, so statements in one card were staggered by 10 pt. | One width (`AsterismLayout.minHitTarget`) on every fact row; Q19 revised. |
| minor | LibraryRepository+WorkDetail.swift — rowTitle default | `rowTitle: String? = nil` let a caller omit it and silently get a titleless row; nothing enforced that key and row title came from the same rule. | `init` derives `rowTitle` from `displayTitle` and `chapterKey`; parameter removed; Q16 revised. |
| minor | WorkDetailView.swift — spine ForEach | `Array(rows.enumerated())` copied the row array on every body evaluation (~60 KB at 500 rows, on every pill/sort tap). | `ForEach(rows)` with `isFirst`/`isLast` derived from the first and last ids. |
| minor | Spec — citation identifiers shadowed | Container identifier on the fact row overrides the children's (`docs/agent-notes/testing.md`), so `work-detail-character-citation` never published. | `.accessibilityElement(children: .contain)` before the container identifier. |
| minor | docs/asterism-design.md:467 | Rejected-alternatives table still said chapter-number parsing was rejected as unreliable, contradicting the rewritten §7 and Decision 2. | Row rewritten to the guarded rule. |
| minor | specs/polish-and-export/requirements.md:16 | Scope line still said no chapter-number parsing or ordering changes. | Marked superseded by this spec. |
| minor | WorkDetailView.swift — fact caption | Two identical `Text` blocks differing only in one identifier. | Built once with a conditional identifier. |
| minor | WorkDetailView.swift — WorkChapterRowView visibility | Internal type with statics exposed module-wide only for the spine row's rail. | Made `private`. |
| minor | Testing — alias chip | Only a negative assertion existed on `work-detail-character-alias-chip`; it would pass if the identifier never published. | Positive assertion added in the new citation test. |
| minor | Testing — ChapterKey label tie-break | `007` vs `7` ordering (Q17) untested. | `labelBreaksTheTie` added. |
| minor | Spec — rail-join check unrecorded | The smolspec's Risks section asks for the check to be recorded; nothing in the spec dir did. | Q24 records the default and XXXL checks. |
| minor | WorkCharacterPresentation.swift — four per-entry dictionaries | `keys:` is the fourth parallel `[UUID: …]` side table threaded through two functions; a `CitedNote` struct would collapse them. | Skipped — `captureOrder`/`titles`/`dates` are pre-existing; collapsing them is a separate refactor. Stale doc comment fixed. |
| minor | WorkDetailView.swift — sort surface vs `constellationCard` | The capsule hand-rolls `cardFill` + `cardBorder` instead of the kit's card recipe. | Skipped — Decision 1 specifies exactly that surface; `constellationCard` adds material and a specular edge the design did not want. |
| minor | WorkDetailView.swift — selected segment as a pill kind | The selected segment's cyan .14/.4 treatment is inline while `selectedTypeTag` went into the recipe file. | Skipped — a segment is not a pill (own geometry, 44 pt target); a kit segmented-control recipe is the right home, out of scope. |
| minor | ChapterKeyDerivation — title scanned twice | `key()` and `rowTitle()` each materialise `[Character]` and prefix-scan the same title; ~1–2 ms at 500 rows inside the locked read. | Skipped — merging the scans would need `init` to know `chapterSequence`, which cuts against Q16; cost is below SwiftData noise. |
| minor | Testing — view-level behaviours | `dateLabel`'s Today/date split, the date-only title line, and Q12's no-caption rule have no UI coverage. | Skipped — the fixture's cited note has no chapter key (Q12 requires the caption there); needs a keyed fixture, left for follow-up. |
| nit | WorkDetailView.swift — date-label comments | Citation date comment claimed to match the spine's date (false for today); spine comment cited a Recent-list precedent that does not exist. | Both comments corrected; behaviour unchanged and intentional. |
| nit | WorkDetailModel.swift — `chapterRowsByChapter` public | Stored order reachable beside `chapterRows(for:)`. | Made `private`. |
| nit | WorkDetailView.swift — duplicated sort backgrounds | Both capsule layouts built fill+stroke by hand. | One `sortSurface(_:)` helper. |
| nit | Spec — hairline only with facts; position tie-break | Two undocumented divergences from the smolspec/Q4. | Recorded as Q22 and Q23. |
| nit | CHANGELOG / OVERVIEW / style guide | Entries sat under `### Added` for a changed screen; OVERVIEW omitted Req 5.1; style guide omitted the scaled segment height. | All three updated. |
| nit | Testing — em-dash separator | `—` in the spec's separator set had no case. | Added to the parameterised trim test. |
| nit | WorkDetailView.swift — notes-count accessibilityLabel | Repeats the Text's own content. | Skipped — the smolspec MUSTs an explicit label on each count. |
| nit | WorkDetailView.swift — hidden gutter as rail sizer | Gutter subtree rendered twice per visible row. | Skipped — the gutter grows with its key label at accessibility sizes; the sizer keeps the rail centred. ~12 visible rows. |
| nit | ChapterKeyDerivation — `lowercased()` allocations; `Character.isWhitespace` vs `M2Unicode` | Per-character String construction in prefix matching; whitespace predicate differs from core's frozen set. | Skipped — ASCII small-string, sub-ms; derivation is not title tokenisation. |
| nit | Stable-sort idiom / rating tint / isASCIIDigit copies | `chapterOrder` mirrors `GroupOrdering.stableSorted`; up/down tint mapped a fifth time; a third `isASCIIDigit`. | Skipped — each alternative is private to another file or target; folding them is a kit-level change. |
| nit | UI test prose says 'rating pulse' | Assertion messages and comments in three UI test files still name the pulse cards. | Skipped — prose only; test files left alone per review constraints. |
| nit | Citation label of last resort | `"this note"` replaces `"Open the note"`; unreachable in practice (every live entry has a date). | Skipped. |
Click to expand.
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex 800b49a..113f5bc 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -30,6 +30,55 @@ 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)++ /// 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).+ case chapter+ }++ /// The Chapter order, derived once per load rather than per body evaluation+ /// (Q13). It is assigned in the same statement as `presentation`, so the two+ /// orders always describe the same moment of the store.+ private var chapterRowsByChapter: [WorkChapterRow] = []++ /// The rows in the order asked for.+ public func chapterRows(for order: ChapterSortOrder) -> [WorkChapterRow] {+ switch order {+ case .newest: chapterRows+ case .chapter: chapterRowsByChapter+ }+ }++ /// Q4's order: numbered notes first, by key ascending, ties oldest-first;+ /// then the unnumbered ones, oldest-first.+ ///+ /// The comparison is total rather than a stable sort over a partial one —+ /// `sorted(by:)` makes no ordering promise for elements it considers equal,+ /// so the last tie-break is the row's position in the repository's order.+ /// 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+ 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+ }+ }.map(\.element)+ }+ // MARK: - Characters (`character-extraction` Reqs 3.2, 3.7, 5.1–5.3) /// The work's characters as the repository ordered them, with their facts in@@ -139,7 +188,11 @@ public final class WorkDetailModel { state = .loading do { let detail = try await library.workDetail(id: workID)- presentation = detail+ // One statement for both orders (Q13): a Chapter order assigned+ // separately is a second moment, and a reload that failed between+ // the two would leave the spine sorting rows the screen no longer+ // shows.+ (presentation, chapterRowsByChapter) = (detail, Self.chapterOrder(detail.chapterRows)) let snapshot = detail.work draftTitle = snapshot.displayTitle draftAssignment = snapshot.typeDisplay.assignment
diff --git a/Asterism/Asterism/Views/WorkDetailView.swift b/Asterism/Asterism/Views/WorkDetailView.swiftindex 9e3b033..18644ba 100644--- a/Asterism/Asterism/Views/WorkDetailView.swift+++ b/Asterism/Asterism/Views/WorkDetailView.swift@@ -57,6 +57,12 @@ struct WorkDetailView: View { /// Which character's editor card is open in the edit session — the same /// fold, per mode, so the two selections do not fight. @State private var expandedEditCharacterID: UUID?+ /// Which order the spine is read in. `@State` and not stored (Q3): Newest is+ /// the reading default and every open of the screen starts there.+ @State private var sortOrder: WorkDetailModel.ChapterSortOrder = .newest+ /// The sort capsule's visible height inside its 44 pt target. Scaled, so the+ /// two labels still fit it at the accessibility text sizes (§10).+ @ScaledMetric(relativeTo: .caption) private var sortSegmentHeight: CGFloat = 32 init( model: WorkDetailModel,@@ -143,9 +149,7 @@ struct WorkDetailView: View { manageSection } else { viewHeaderSection(work)- pulseSection openLastNotedSection- viewNotesSection(work) charactersSection chapterSection }@@ -267,9 +271,15 @@ struct WorkDetailView: View { // MARK: - View mode - /// View mode's header: the work's full title, the site glyph, the site line- /// with its URL identity, the link to the work's own page, and the type and- /// genre tags as chips.+ /// View mode's header (`work-detail-reading-redesign`): the work's full+ /// title, one site row — glyph, hostname and URL identity, the meta line and+ /// the link to the work's own page — the type and genre tags, and the+ /// work's own notes as a paragraph.+ ///+ /// No card, and no card around the counts or the notes either. Four stacked+ /// glass surfaces used to sit between the title and the first note, so the+ /// screen opened on its own chrome; the header is now text on the sky and+ /// the notes start where they are read. /// /// The title is here *and* in the navigation bar (Q58), and the two are not /// duplicates of each other: the bar carries the collapsed form, which is@@ -278,7 +288,7 @@ struct WorkDetailView: View { @ViewBuilder private func viewHeaderSection(_ work: WorkSnapshot) -> some View { Section {- VStack(alignment: .leading, spacing: 10) {+ VStack(alignment: .leading, spacing: 12) { // No `lineLimit`: this is the one place the whole title is // readable, which is the point of it being here (Q58). Text(work.displayTitle)@@ -289,41 +299,22 @@ struct WorkDetailView: View { .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("work-detail-title") - HStack(alignment: .top, spacing: 12) {- SiteGlyph(hostname: work.siteHostname, size: 44)- .accessibilityHidden(true)-- VStack(alignment: .leading, spacing: 4) {- Text(work.siteHostname)- .font(.caption)- .foregroundStyle(AsterismColors.secondaryText)- .lineLimit(1)- .accessibilityIdentifier("work-detail-hostname")- // §3: URL chips are monospace and dim.- if let identity = work.urlIdentity, !identity.isEmpty {- Text(identity)- .font(AsterismTypography.mono)- .foregroundStyle(AsterismColors.secondaryText)- .lineLimit(1)- .accessibilityIdentifier("work-detail-url-identity")- }+ // The site row, and the counts on it where they fit. The same+ // fallback the sort control takes (Decision 1): rather than+ // squeeze the hostname to one letter at the accessibility text+ // sizes, the counts drop to their own line.+ ViewThatFits(in: .horizontal) {+ HStack(spacing: 12) {+ siteIdentityRow(work)+ metaLine+ linkGlyph(work) }-- Spacer(minLength: 0)-- // Decision 5: entry detail's `link` glyph, for the same- // reason — the work's own page had nothing pointing at it- // from view mode, only a text field behind an edit sheet.- if let workURL = work.workURLString.flatMap(URL.init(string:)) {- Link(destination: workURL) {- Image(systemName: "link")- .frame(- minWidth: AsterismLayout.minHitTarget,- minHeight: AsterismLayout.minHitTarget)- .contentShape(Rectangle())+ VStack(alignment: .leading, spacing: 8) {+ HStack(spacing: 12) {+ siteIdentityRow(work)+ linkGlyph(work) }- .accessibilityIdentifier("work-detail-link")- .accessibilityLabel("Open this work's page")+ metaLine } } @@ -346,13 +337,102 @@ struct WorkDetailView: View { .accessibilityElement(children: .contain) .accessibilityIdentifier("work-detail-tags") }++ // The work's own notes read as the lede of the page: a+ // paragraph, no card and no section header of its own. Absent+ // when there are none — an empty field on a read screen invites+ // an edit the screen is not offering.+ if !work.genericNotes.isEmpty {+ Text(work.genericNotes)+ .font(.subheadline)+ .foregroundStyle(AsterismColors.noteText)+ .lineSpacing(4)+ .fixedSize(horizontal: false, vertical: true)+ .frame(maxWidth: .infinity, alignment: .leading)+ .accessibilityIdentifier("work-detail-notes")+ } }- .padding(12)- .constellationCard() .constellationListRow() } } + /// The site glyph and the two lines that name where the work lives.+ private func siteIdentityRow(_ work: WorkSnapshot) -> some View {+ HStack(spacing: 12) {+ SiteGlyph(hostname: work.siteHostname, size: 40)+ .accessibilityHidden(true)++ VStack(alignment: .leading, spacing: 2) {+ Text(work.siteHostname)+ .font(.caption)+ .foregroundStyle(AsterismColors.secondaryText)+ .lineLimit(1)+ .accessibilityIdentifier("work-detail-hostname")+ // §3: URL chips are monospace and dim.+ if let identity = work.urlIdentity, !identity.isEmpty {+ Text(identity)+ .font(AsterismTypography.mono)+ .foregroundStyle(AsterismColors.secondaryText)+ .lineLimit(1)+ .accessibilityIdentifier("work-detail-url-identity")+ }+ }++ Spacer(minLength: 8)+ }+ }++ /// Decision 5: entry detail's `link` glyph, for the same reason — the work's+ /// own page had nothing pointing at it from view mode, only a text field+ /// behind an edit sheet.+ @ViewBuilder+ private func linkGlyph(_ work: WorkSnapshot) -> some View {+ if let workURL = work.workURLString.flatMap(URL.init(string:)) {+ Link(destination: workURL) {+ Image(systemName: "link")+ .frame(+ minWidth: AsterismLayout.minHitTarget,+ minHeight: AsterismLayout.minHitTarget)+ .contentShape(Rectangle())+ }+ .accessibilityIdentifier("work-detail-link")+ .accessibilityLabel("Open this work's page")+ }+ }++ /// Req 5.2's three counts, over logical records — `{n} notes ▲ up ▼ down`+ /// on one line where three equal cards used to be.+ ///+ /// The identifiers and labels are the cards' own, because what the counts+ /// are is unchanged: only the surface they are drawn on went. It wraps+ /// rather than truncates: a truncated count is a wrong count.+ private var metaLine: some View {+ FlowLayout(spacing: 8) {+ Text(Pluralisation.count(model.pulse.notes, "note", "notes"))+ .font(.caption.weight(.semibold))+ .foregroundStyle(AsterismColors.primaryText)+ .accessibilityLabel(Pluralisation.count(model.pulse.notes, "note", "notes"))+ .accessibilityIdentifier("work-detail-pulse-notes")+ Text("▲ \(model.pulse.up)")+ .font(.caption)+ .foregroundStyle(AsterismColors.cyan)+ .accessibilityLabel("\(model.pulse.up) rated up")+ .accessibilityIdentifier("work-detail-pulse-up")+ Text("▼ \(model.pulse.down)")+ .font(.caption)+ .foregroundStyle(AsterismColors.violet)+ .accessibilityLabel("\(model.pulse.down) rated down")+ .accessibilityIdentifier("work-detail-pulse-down")+ }+ .lineLimit(1)+ // `children: .contain` before the identifier, or the group's identifier+ // propagates down and overwrites each count's own — the same trap+ // `ComposedTeachingView`'s acknowledgment panel records. The three have+ // to stay separately addressable.+ .accessibilityElement(children: .contain)+ .accessibilityIdentifier("work-detail-pulse")+ }+ // MARK: - Edit mode /// Edit mode's header: the title, the type, and the genre tags — the three@@ -409,62 +489,6 @@ struct WorkDetailView: View { } } - /// Req 5.2: three counts over logical records, no drill-down.- ///- /// §7's rating pulse: three equal cards, the ▲ count cyan, the ▼ count- /// violet, the notes count neutral.- private var pulseSection: some View {- Section {- HStack(spacing: 10) {- pulseCount(- String(model.pulse.notes),- tint: AsterismColors.primaryText,- label: "notes",- identifier: "work-detail-pulse-notes")- pulseCount(- "▲ \(model.pulse.up)",- tint: AsterismColors.cyan,- label: "rated up",- identifier: "work-detail-pulse-up")- pulseCount(- "▼ \(model.pulse.down)",- tint: AsterismColors.violet,- label: "rated down",- identifier: "work-detail-pulse-down")- }- // `children: .contain` before the identifier, or the group's- // identifier propagates down and overwrites each card's own —- // the same trap `ComposedTeachingView`'s acknowledgment panel- // records. The three cards have to stay separately addressable.- .accessibilityElement(children: .contain)- .accessibilityIdentifier("work-detail-pulse")- .constellationListRow()- }- }-- private func pulseCount(- _ text: String,- tint: Color,- label: String,- identifier: String- ) -> some View {- VStack(spacing: 2) {- Text(text)- .font(.headline.weight(.heavy))- .foregroundStyle(tint)- .lineLimit(1)- Text(label)- .font(.caption2)- .foregroundStyle(AsterismColors.secondaryText)- .lineLimit(1)- }- .frame(maxWidth: .infinity)- .padding(.vertical, 12)- .constellationCard()- .accessibilityElement(children: .combine)- .accessibilityIdentifier(identifier)- }- /// Req 5.3. Hidden entirely where the work has no entries — the repository /// decides that by returning no URL, so the screen never has to guess. @ViewBuilder@@ -483,27 +507,6 @@ struct WorkDetailView: View { } } - /// View mode's generic notes: read, not typed. Hidden entirely when there- /// are none — an empty field on a read screen invites an edit the screen is- /// not offering.- @ViewBuilder- private func viewNotesSection(_ work: WorkSnapshot) -> some View {- if !work.genericNotes.isEmpty {- Section {- Text(work.genericNotes)- .font(.body)- .foregroundStyle(AsterismColors.noteText)- .frame(maxWidth: .infinity, alignment: .leading)- .accessibilityIdentifier("work-detail-notes")- .padding(12)- .constellationCard()- .constellationListRow()- } header: {- ConstellationSectionHeader("Notes")- }- }- }- private var editNotesSection: some View { Section { TextField("Notes", text: $model.draftNotes, axis: .vertical)@@ -520,34 +523,148 @@ struct WorkDetailView: View { /// Req 5.4: the chapter-notes list, from `WorkChapterRow` — display titles /// cleaned by the repository, not by this view. ///+ /// `work-detail-reading-redesign`: the rows are the spine. Each carries its+ /// own segment of the rail (Q2 — the list stays a `List`, so a single+ /// overlay would have to survive recycling at M4 scale), which is why the+ /// row knows whether it is the first or the last: those two cap their+ /// segments at the dot instead of running past it.+ /// /// Q56: the rows open their entries. They listed a work's notes and /// were the one list in the app that could not be tapped through to what it /// listed. @ViewBuilder private var chapterSection: some View {- if !model.chapterRows.isEmpty {+ let rows = model.chapterRows(for: sortOrder)+ if !rows.isEmpty {+ let firstID = rows.first?.id+ let lastID = rows.last?.id Section {- ForEach(model.chapterRows) { row in- Group {- if let onSelectEntry {- Button { onSelectEntry(row.id) } label: {- WorkChapterRowView(row: row)- }- .buttonStyle(.plain)- } else {- WorkChapterRowView(row: row)- }- }- .accessibilityIdentifier("work-detail-entry")- .accessibilityLabel("Note on \(row.displayTitle)")- .constellationListRow()+ ForEach(rows) { row in+ WorkChapterSpineRow(+ row: row,+ isFirst: row.id == firstID,+ isLast: row.id == lastID,+ onSelectEntry: onSelectEntry)+ // Zero vertical inset: the spacing between rows is the+ // row's own padding, so consecutive rail segments meet.+ .constellationListRow(+ insets: EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } } header: {+ chapterSectionHeader+ }+ }+ }++ /// Decision 1: the section header and the order it is read in, on one line+ /// where they fit and stacked where they do not. Both segments stay visible+ /// either way — which order the spine is in is a state to see, not a label+ /// to open a menu for.+ private var chapterSectionHeader: some View {+ ViewThatFits(in: .horizontal) {+ HStack(spacing: 12) {+ ConstellationSectionHeader("Chapter Notes", accent: .violet)+ Spacer(minLength: 8)+ sortControl+ }+ VStack(alignment: .leading, spacing: 6) { ConstellationSectionHeader("Chapter Notes", accent: .violet)+ sortControl } } } + /// The two-segment capsule. `.plain` on both, as the cast pills are: sibling+ /// buttons in one row bleed their hit areas into each other otherwise.+ ///+ /// At the largest accessibility sizes the two labels cannot share a line+ /// even on a line of their own, so a second candidate stacks them. §10+ /// forbids clipping and truncation, and a hyphenated control label reads no+ /// better than either.+ private var sortControl: some View {+ ViewThatFits(in: .horizontal) {+ HStack(spacing: 0) {+ sortSegments(fillsWidth: false)+ }+ .background {+ sortSurface(Capsule())+ .frame(height: sortSegmentHeight)+ }++ VStack(alignment: .leading, spacing: 0) {+ sortSegments(fillsWidth: true)+ }+ .background {+ // A capsule that tall reads as a pill lying on its side; the+ // card radius is the shape the rest of the screen encloses with.+ sortSurface(+ RoundedRectangle(cornerRadius: AsterismLayout.cardRadius, style: .continuous))+ }+ }+ }++ /// Decision 1's surface: `cardFill` inside a 1 pt `cardBorder`, on whichever+ /// shape the layout needs.+ private func sortSurface(_ shape: some InsettableShape) -> some View {+ shape+ .fill(AsterismColors.cardFill)+ .overlay { shape.strokeBorder(AsterismColors.cardBorder, lineWidth: 1) }+ .allowsHitTesting(false)+ }++ /// Both segments, so the two layouts of the control share one definition.+ @ViewBuilder+ private func sortSegments(fillsWidth: Bool) -> some View {+ sortSegment(+ "Newest", order: .newest, identifier: "work-detail-sort-newest",+ fillsWidth: fillsWidth)+ sortSegment(+ "Chapter", order: .chapter, identifier: "work-detail-sort-chapter",+ fillsWidth: fillsWidth)+ }++ private func sortSegment(+ _ title: String, order: WorkDetailModel.ChapterSortOrder, identifier: String,+ fillsWidth: Bool+ ) -> some View {+ let isSelected = sortOrder == order+ return Button {+ sortOrder = order+ } label: {+ Text(title)+ .font(.caption.weight(.semibold))+ // A segment label never wraps: it is what the capsule is+ // measured by, and "New-/est" is not a control label.+ .lineLimit(1)+ .fixedSize(horizontal: true, vertical: false)+ .foregroundStyle(isSelected ? AsterismColors.cyan : AsterismColors.secondaryText)+ .padding(.horizontal, 14)+ // §10: the visual is 32 pt, the target is 44. Stacked, the+ // segment spans the width so its selected fill does too. The+ // scaled visual is the floor once it passes 44, or the fill+ // would overflow the row it is drawn behind.+ .frame(+ maxWidth: fillsWidth ? .infinity : nil,+ minHeight: max(AsterismLayout.minHitTarget, sortSegmentHeight))+ .background {+ if isSelected {+ Capsule()+ .fill(AsterismColors.cyan.opacity(0.14))+ .overlay {+ Capsule().strokeBorder(AsterismColors.cyan.opacity(0.4), lineWidth: 1)+ }+ .frame(height: sortSegmentHeight)+ }+ }+ .contentShape(Rectangle())+ }+ .buttonStyle(.plain)+ .accessibilityIdentifier(identifier)+ // The precedent entry detail's rating toggles and the Stats bars set:+ // the chosen one says so, and the choice clears with it.+ .accessibilityAddTraits(isSelected ? [.isSelected] : [])+ }+ /// Edit mode's structural actions (Decision 5). Merge and Delete change what /// records exist, which is not something a reader does while reading — they /// are reached the same way a title edit is, and they keep the presented-@@ -814,9 +931,10 @@ struct WorkDetailView: View { /// element, so nothing inside is masked and the pill count is the cast /// count. private func characterPill(_ character: WorkCharacterPresentation) -> some View {- Button {+ let isExpanded = expandedCharacterID == character.id+ return Button { withAnimation(.snappy) {- expandedCharacterID = expandedCharacterID == character.id ? nil : character.id+ expandedCharacterID = isExpanded ? nil : character.id } } label: { HStack(spacing: 5) {@@ -831,22 +949,33 @@ struct WorkDetailView: View { .foregroundStyle(AsterismColors.secondaryText) Image(systemName: "chevron.down") .font(.caption2)- .rotationEffect(.degrees(expandedCharacterID == character.id ? 180 : 0))+ .rotationEffect(.degrees(isExpanded ? 180 : 0)) }- .constellationPill(.typeTag)+ // Q14: the open pill is a recipe, not a one-off — the same violet,+ // lifted, so the card below it is visibly this name's card.+ .constellationPill(isExpanded ? .selectedTypeTag : .typeTag) } .buttonStyle(.plain) .accessibilityIdentifier("work-detail-character") .accessibilityLabel("\(character.name), \(character.facts.count) facts") } - /// The one expanded character: aliases, note, then the facts with their- /// citations — everything the old always-open row showed, on request.+ /// The one expanded character: the name and its aliases, the note, then the+ /// facts on the same gutter the spine uses — everything the old always-open+ /// row showed, on request and laid out to be read. private func characterDetailCard(_ character: WorkCharacterPresentation) -> some View {- VStack(alignment: .leading, spacing: 6) {- HStack(spacing: 8) {+ VStack(alignment: .leading, spacing: 10) {+ // Q14: the aliases wrap beside the name as `.count` chips, the same+ // recipe the editor's alias chips already use. "Also Nightjar, Jay"+ // was a sentence about match keys; these are the keys.+ FlowLayout(spacing: 8) { Text(character.name) .font(AsterismTypography.serifHeading)+ ForEach(character.aliases, id: \.self) { alias in+ Text(alias)+ .constellationPill(.count)+ .accessibilityIdentifier("work-detail-character-alias-chip")+ } if character.isTorn { // Req 6.5: resolved where every torn record is. Label("Differing copies", systemImage: "exclamationmark.circle")@@ -855,18 +984,24 @@ struct WorkDetailView: View { .accessibilityIdentifier("work-detail-character-torn") } }- if !character.aliases.isEmpty {- Text("Also \(character.aliases.joined(separator: ", "))")- .font(.caption)- .foregroundStyle(AsterismColors.cyan)- }+ .frame(maxWidth: .infinity, alignment: .leading)+ if !character.note.isEmpty { Text(character.note)- .font(.footnote)- .foregroundStyle(AsterismColors.secondaryText)+ .font(.subheadline)+ .foregroundStyle(AsterismColors.noteText)+ .lineSpacing(4)+ .fixedSize(horizontal: false, vertical: true)+ .frame(maxWidth: .infinity, alignment: .leading) }- ForEach(character.facts) { fact in- characterFactRow(fact)++ if !character.facts.isEmpty {+ Rectangle()+ .fill(AsterismColors.cardBorder)+ .frame(height: 1)+ ForEach(character.facts) { fact in+ characterFactRow(fact)+ } } } .frame(maxWidth: .infinity, alignment: .leading)@@ -878,39 +1013,124 @@ struct WorkDetailView: View { /// Req 5.2: each citation opens the note it came from, where that note still /// exists. A dangling citation displays without navigation and is not an /// error (Req 3.5) — the statement and the quote are still the reader's.- @ViewBuilder+ ///+ /// The citation moves into the gutter (Q10): where the cited note has a+ /// chapter key it is the number the spine draws, so the fact says which+ /// chapter it came from without a caption to read (Q12). private func characterFactRow(_ fact: WorkCharacterFactRow) -> some View {- VStack(alignment: .leading, spacing: 2) {- Text(fact.statement)- .font(.footnote)- if let entryID = fact.citedEntryID, let onSelectEntry {- // The chapter title where the note has one, its own title where- // that says more than the work's name — and the capture date- // otherwise, because on the work's own page a link labelled- // with the work's title identifies nothing.- Button(fact.citationTitle ?? citationDateLabel(fact)) { onSelectEntry(entryID) }- .font(.caption)- .buttonStyle(.plain)- .foregroundStyle(AsterismColors.cyan)- .accessibilityIdentifier("work-detail-character-citation")- } else if fact.isDangling {- Text("From a note that is no longer here")- .font(.caption2)- .foregroundStyle(.tertiary)- .accessibilityIdentifier("work-detail-character-citation-dangling")- } else {- Text("From this work's notes")- .font(.caption2)- .foregroundStyle(.tertiary)+ HStack(alignment: .top, spacing: 8) {+ citationGutter(fact)+ VStack(alignment: .leading, spacing: 2) {+ Text(fact.statement)+ .font(.subheadline)+ .foregroundStyle(AsterismColors.noteText)+ .fixedSize(horizontal: false, vertical: true)+ if let caption = citationCaption(fact) {+ Text(caption)+ .font(.caption2)+ .foregroundStyle(.tertiary)+ .accessibilityIdentifier(+ fact.isDangling ? "work-detail-character-citation-dangling" : "")+ } }+ .frame(maxWidth: .infinity, alignment: .leading) }+ // A container identifier is inherited by every descendant that declares+ // its own, so without this the citation button publishes as+ // `work-detail-character-fact` too and nothing can reach it+ // (`docs/agent-notes/testing.md`). `.contain` keeps the row findable as+ // a group while its children keep their own identifiers.+ .accessibilityElement(children: .contain) .accessibilityIdentifier("work-detail-character-fact") } - /// The citation's label of last resort: the note's capture date, matching- /// the date the chapter list shows for the same note.+ /// The gutter beside a fact: a link to the note it came from, or a dash+ /// where there is no note to open.+ @ViewBuilder+ private func citationGutter(_ fact: WorkCharacterFactRow) -> some View {+ if let entryID = fact.citedEntryID {+ if let onSelectEntry {+ Button { onSelectEntry(entryID) } label: {+ citationGlyph(fact)+ // Req 11.2's target, aligned to the top of it so the+ // number sits on the statement's first line rather than+ // halfway down the fact.+ .frame(+ minWidth: Self.factGutterWidth,+ minHeight: AsterismLayout.minHitTarget,+ alignment: .top)+ .contentShape(Rectangle())+ }+ .buttonStyle(.plain)+ .accessibilityIdentifier("work-detail-character-citation")+ .accessibilityLabel("Open \(citationName(fact))")+ } else {+ // A host with no entry route — the Merge screen's embedded copy,+ // and every preview. The gutter still says where the fact came+ // from; it just does not go there.+ citationGlyph(fact)+ .frame(minWidth: Self.factGutterWidth)+ }+ } else {+ // The work's own notes, or a note that is gone: nothing to open, so+ // the gutter holds a dash and the caption below says which it is.+ // A `Text`, not a drawn rule: it shares the statement's metrics and+ // so sits on its first line at every text size.+ Text(verbatim: "\u{2014}")+ .font(.subheadline)+ .foregroundStyle(AsterismColors.secondaryText)+ .frame(minWidth: Self.factGutterWidth)+ .accessibilityHidden(true)+ }+ }++ /// Q10: the cited note's chapter number, or an arrow where the rules do not+ /// number it — the gutter has to read as a link either way, and a bare "Ch."+ /// would read as a number that failed to render.+ @ViewBuilder+ private func citationGlyph(_ fact: WorkCharacterFactRow) -> some View {+ if let label = fact.citedChapterKey?.label {+ Text(label)+ .font(AsterismTypography.mono.weight(.semibold))+ .foregroundStyle(AsterismColors.cyan)+ .lineLimit(1)+ } else {+ Image(systemName: "arrow.up.right")+ .font(.caption)+ .foregroundStyle(AsterismColors.cyan)+ }+ }++ /// Q12: a live citation with a key says everything in its gutter. Without a+ /// key the arrow alone would not, so the note's title or date stays.+ private func citationCaption(_ fact: WorkCharacterFactRow) -> String? {+ if fact.citedEntryID != nil {+ return fact.citedChapterKey == nil ? citationName(fact) : nil+ }+ return fact.isDangling+ ? "From a note that is no longer here"+ : "From this work's notes"+ }++ /// What to call the cited note: the chapter title where the note has one,+ /// its own title where that says more than the work's name — and the capture+ /// date otherwise, because on the work's own page a link labelled with the+ /// work's title identifies nothing.+ private func citationName(_ fact: WorkCharacterFactRow) -> String {+ fact.citationTitle ?? citationDateLabel(fact)+ }++ /// The fact gutter's width, the same on every row of a card so the+ /// statements beside it line up (Q19). It is the hit target's width because+ /// a live citation's button has to be that wide; the dash rows match it+ /// rather than sit 10 pt narrower and stagger the column.+ private static let factGutterWidth: CGFloat = AsterismLayout.minHitTarget++ /// The citation's label of last resort: the note's capture date. Always the+ /// abbreviated date — unlike the spine's "Today", it is read in a sentence+ /// ("Noted 12 Aug"), where the relative form would not scan. private func citationDateLabel(_ fact: WorkCharacterFactRow) -> String {- guard let date = fact.citationDate else { return "Open the note" }+ guard let date = fact.citationDate else { return "this note" } return "Noted \(date.formatted(date: .abbreviated, time: .omitted))" } @@ -1234,11 +1454,6 @@ struct WorkDetailView: View { } } -/// One row of the chapter-notes list (Req 5.4): rating glyph, chapter title,-/// note preview, date.-///-/// Its own row type rather than `UnattachedEntryRow`: that one renders the raw-/// capture title, which is exactly what 5.4 says this list must not do. /// Aliases in the character editor: each a removable chip, plus a field to add /// one. Its own view because the add field needs per-card state. private struct CharacterAliasEditor: View {@@ -1289,43 +1504,182 @@ private struct CharacterAliasEditor: View { } } -struct WorkChapterRowView: View {+/// One note on the spine: the rail behind it, the tappable row in front.+///+/// The rail is drawn in *this* container's background rather than inside the+/// `Button`, so pressing a note does not dim the line running through it and+/// the line does not widen the tap area. The first and last rows cap their+/// segment at the dot; every other segment overdraws half a point at each end,+/// so a row boundary never shows as a hairline gap.+private struct WorkChapterSpineRow: View { let row: WorkChapterRow+ let isFirst: Bool+ let isLast: Bool+ let onSelectEntry: ((UUID) -> Void)? var body: some View {- VStack(alignment: .leading, spacing: 4) {- HStack(spacing: 6) {- if let rating = row.rating {- Text(rating == .up ? "▲" : "▼")- .font(.caption)- .foregroundStyle(- rating == .up ? AsterismColors.cyan : AsterismColors.violet)- .accessibilityIdentifier("work-detail-entry-rating")+ Group {+ if let onSelectEntry {+ Button { onSelectEntry(row.id) } label: {+ WorkChapterRowView(row: row)+ }+ .buttonStyle(.plain)+ } else {+ WorkChapterRowView(row: row)+ }+ }+ .accessibilityIdentifier("work-detail-entry")+ .accessibilityLabel(rowLabel)+ // The note itself is the row's value: a reader listening to the list+ // hears what they wrote, not a preview of it.+ .accessibilityValue(row.note)+ .background(alignment: .topLeading) { rail }+ }++ /// The *untrimmed* title, plus the rating where there is one. The gutter's+ /// number and the trimmed row title are a reading convenience; someone who+ /// cannot see them still needs the whole name and the rating the dot's+ /// colour carries.+ private var rowLabel: String {+ switch row.rating {+ case .up: "Note on \(row.displayTitle), rated up"+ case .down: "Note on \(row.displayTitle), rated down"+ case nil: "Note on \(row.displayTitle)"+ }+ }++ private var rail: some View {+ HStack(spacing: 0) {+ // The gutter again, hidden. It is the only thing that knows how wide+ // the gutter came out — it grows with its key label — and the rail+ // has to run through the dots, not beside them. The line is an+ // *overlay* on it rather than a `ZStack` sibling: a `GeometryReader`+ // beside it would claim the whole row's width and centre the rail on+ // the page.+ WorkChapterRowView.gutter(for: row)+ .hidden()+ // The gutter is only as tall as its dot and number; the rail has+ // to span the whole row, so the sizer is stretched first.+ .frame(maxHeight: .infinity)+ .overlay {+ GeometryReader { proxy in+ let top = isFirst ? WorkChapterRowView.dotCentreOffset : -0.5+ let bottom =+ isLast+ ? WorkChapterRowView.dotCentreOffset+ : proxy.size.height + 0.5+ Rectangle()+ .fill(AsterismColors.cardBorder)+ .frame(width: 1, height: max(0, bottom - top))+ .offset(x: (proxy.size.width - 1) / 2, y: top)+ }+ }+ Spacer(minLength: 0)+ }+ .allowsHitTesting(false)+ }+}++/// One note's own content: the gutter — a rating dot over the chapter number —+/// and the title line with the full note beneath it.+///+/// No card and no line limit. A card per note put a border between the reader+/// and every paragraph, and two clamped lines showed titles where the point of+/// the screen is the notes.+private struct WorkChapterRowView: View {+ let row: WorkChapterRow++ /// Where the rail's end caps land: the row's own top inset plus the dot's+ /// inset and half its height. Fixed, like the dot itself.+ static let dotCentreOffset: CGFloat = verticalInset + dotTopInset + dotSize / 2++ private static let verticalInset: CGFloat = 10+ private static let dotTopInset: CGFloat = 3+ private static let dotSize: CGFloat = 10++ var body: some View {+ HStack(alignment: .top, spacing: 10) {+ Self.gutter(for: row)+ VStack(alignment: .leading, spacing: 4) {+ titleLine+ if !row.note.isEmpty {+ Text(row.note)+ .font(.subheadline)+ .foregroundStyle(AsterismColors.noteText)+ .lineSpacing(4)+ .fixedSize(horizontal: false, vertical: true)+ .frame(maxWidth: .infinity, alignment: .leading) }- Text(row.displayTitle)- .font(AsterismTypography.serifRowTitle)+ }+ }+ .padding(.vertical, Self.verticalInset)+ .frame(maxWidth: .infinity, alignment: .leading)+ .contentShape(Rectangle())+ }++ /// Q9: the sans face at 13 pt semibold, not `serifRowTitle` — §3 reserves+ /// serif for the titles of works and screens. Where the repository trimmed+ /// the title away entirely (Q5), the line is the date alone.+ private var titleLine: some View {+ HStack(alignment: .firstTextBaseline, spacing: 8) {+ if let rowTitle = row.rowTitle, !rowTitle.isEmpty {+ Text(rowTitle)+ .font(.footnote.weight(.semibold))+ .foregroundStyle(AsterismColors.primaryText) .lineLimit(1) .truncationMode(.tail)- .foregroundStyle(AsterismColors.primaryText)- .accessibilityIdentifier("work-detail-entry-title")- Spacer()- Text(row.lastSharedAt, style: .date)- .font(.caption2)- .foregroundStyle(AsterismColors.secondaryText) }+ Spacer(minLength: 8)+ Text(Self.dateLabel(row.lastSharedAt))+ .font(.caption2)+ .foregroundStyle(AsterismColors.secondaryText)+ .lineLimit(1)+ }+ } - if !row.note.isEmpty {- Text(row.note)- .font(.caption)- .foregroundStyle(AsterismColors.noteText)- .lineLimit(2)- .accessibilityIdentifier("work-detail-entry-note")+ /// The dot and, beneath it, the chapter number as the site wrote it —+ /// nothing beneath it where the note has no number.+ ///+ /// A static function rather than a property because the rail needs a hidden+ /// copy of it to know its own width, and that copy is drawn outside this+ /// view.+ @ViewBuilder+ static func gutter(for row: WorkChapterRow) -> some View {+ VStack(spacing: 4) {+ ratingDot(row.rating)+ .padding(.top, dotTopInset)+ if let label = row.chapterKey?.label {+ Text(label)+ .font(AsterismTypography.mono)+ .foregroundStyle(AsterismColors.secondaryText)+ .lineLimit(1) } }- .frame(maxWidth: .infinity, alignment: .leading)- .padding(.vertical, 10)- .padding(.horizontal, 12)- .frame(minHeight: AsterismLayout.minHitTarget)- .constellationCard()+ .frame(minWidth: AsterismLayout.minHitTarget)+ }++ /// Q11: no glow. §5 puts glow on identity and primary action, and a rating+ /// is neither.+ @ViewBuilder+ private static func ratingDot(_ rating: Rating?) -> some View {+ switch rating {+ case .up:+ Circle().fill(AsterismColors.cyan).frame(width: dotSize, height: dotSize)+ case .down:+ Circle().fill(AsterismColors.violet).frame(width: dotSize, height: dotSize)+ case nil:+ Circle()+ .strokeBorder(AsterismColors.secondaryText, lineWidth: 1.5)+ .frame(width: dotSize, height: dotSize)+ }+ }++ /// "Today" for today's notes, the abbreviated date for everything else: a+ /// note written this session is the one the reader is most likely looking+ /// for, and a date says less than the word.+ private static func dateLabel(_ date: Date) -> String {+ Calendar.current.isDateInToday(date)+ ? "Today"+ : date.formatted(date: .abbreviated, time: .omitted) } }
diff --git a/Asterism/AsterismTests/WorkDetailModelTests.swift b/Asterism/AsterismTests/WorkDetailModelTests.swiftindex 76f59ac..f99a22f 100644--- a/Asterism/AsterismTests/WorkDetailModelTests.swift+++ b/Asterism/AsterismTests/WorkDetailModelTests.swift@@ -688,6 +688,99 @@ struct WorkDetailModelTests { // Still the same reload path, and still the §6 read. #expect(mock.workDetailCallCount == 2) }++ // MARK: - The spine's two orders (`work-detail-reading-redesign` Q4, Q13)++ /// 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.+ private nonisolated static func row(+ _ title: String, key: ChapterKey? = nil, sharedAt: Date+ ) -> WorkChapterRow {+ WorkChapterRow(+ id: UUID(), displayTitle: title, note: "", rating: nil, lastSharedAt: sharedAt,+ chapterKey: key)+ }++ private nonisolated static func key(_ components: [Int], _ label: String) -> ChapterKey {+ ChapterKey(components: components, label: label)+ }++ private nonisolated static func detail(_ rows: [WorkChapterRow]) -> WorkDetailPresentation {+ TestFixtures.makeWorkDetail(+ work: TestFixtures.makeWork(displayTitle: "Serial"), chapterRows: rows)+ }++ /// Keys compare component-wise, so `5.07` sorts before `5.10` rather than+ /// after it as a decimal would.+ @MainActor @Test("The Chapter order runs the numbered notes by key ascending")+ func chapterOrderAscendsByKey() async {+ let rows = [+ Self.row("6.01", key: Self.key([6, 1], "6.01"), sharedAt: TestFixtures.fixedDate),+ Self.row("5.10", key: Self.key([5, 10], "5.10"), sharedAt: TestFixtures.earlierDate),+ Self.row("5.07", key: Self.key([5, 7], "5.07"), sharedAt: TestFixtures.laterDate),+ Self.row("12.5", key: Self.key([12, 5], "12.5"), sharedAt: TestFixtures.fixedDate),+ Self.row("12", key: Self.key([12], "12"), sharedAt: TestFixtures.fixedDate),+ ]+ let (model, _, _) = makeSUT(detail: Self.detail(rows))++ await model.load()++ #expect(+ model.chapterRows(for: .chapter).map(\.displayTitle)+ == ["5.07", "5.10", "6.01", "12", "12.5"])+ }++ /// Two notes numbered the same read oldest-first, which is the direction+ /// every other run in an ascending list reads.+ @MainActor @Test("Notes sharing a key are ordered oldest-first")+ func chapterOrderBreaksKeyTiesByDate() async {+ let rows = [+ Self.row("newer 7", key: Self.key([7], "7"), sharedAt: TestFixtures.laterDate),+ Self.row("older 7", key: Self.key([7], "7"), sharedAt: TestFixtures.earlierDate),+ Self.row("8", key: Self.key([8], "8"), sharedAt: TestFixtures.fixedDate),+ ]+ let (model, _, _) = makeSUT(detail: Self.detail(rows))++ await model.load()++ #expect(+ model.chapterRows(for: .chapter).map(\.displayTitle) == ["older 7", "newer 7", "8"])+ }++ /// Q4: the unnumbered notes sit below every numbered one, oldest-first among+ /// themselves.+ @MainActor @Test("Unnumbered notes tail the Chapter order, oldest-first")+ func chapterOrderTailsTheUnnumbered() async {+ let rows = [+ Self.row("newer interlude", sharedAt: TestFixtures.laterDate),+ Self.row("2", key: Self.key([2], "2"), sharedAt: TestFixtures.fixedDate),+ Self.row("older interlude", sharedAt: TestFixtures.earlierDate),+ Self.row("1", key: Self.key([1], "1"), sharedAt: TestFixtures.laterDate),+ ]+ let (model, _, _) = makeSUT(detail: Self.detail(rows))++ await model.load()++ #expect(+ model.chapterRows(for: .chapter).map(\.displayTitle)+ == ["1", "2", "older interlude", "newer interlude"])+ }++ /// 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),+ ]+ let (model, _, _) = makeSUT(detail: Self.detail(rows))++ await model.load()++ #expect(model.chapterRows(for: .newest).map(\.id) == rows.map(\.id))+ #expect(model.chapterRows(for: .newest).map(\.id) == model.chapterRows.map(\.id))+ } } /// Records the conflicts a model hands off, from the `@Sendable` closure the
diff --git a/Asterism/AsterismUITests/CharacterExtractionUITests.swift b/Asterism/AsterismUITests/CharacterExtractionUITests.swiftindex fa535db..fe12831 100644--- a/Asterism/AsterismUITests/CharacterExtractionUITests.swift+++ b/Asterism/AsterismUITests/CharacterExtractionUITests.swift@@ -95,6 +95,29 @@ final class CharacterExtractionUITests: XCTestCase { pill.coordinate(withNormalizedOffset: CGVector(dx: 0.08, dy: 0.5)).tap() } + /// The cast pill for one name. The pills all carry the same identifier, so+ /// the name they are labelled with is what tells them apart.+ private func pill(named name: String) -> XCUIElement {+ app.descendants(matching: .any)+ .matching(+ NSPredicate(+ format: "identifier == %@ AND label BEGINSWITH %@",+ "work-detail-character", name))+ .firstMatch+ }++ /// The alias chip carrying `name` on the open character card (Q14 of+ /// `work-detail-reading-redesign`). Identifier *and* label: every alias on+ /// the card is one identifier, so only the label says which one.+ private func aliasChip(named name: String) -> XCUIElement {+ app.descendants(matching: .any)+ .matching(+ NSPredicate(+ format: "identifier == %@ AND label == %@",+ "work-detail-character-alias-chip", name))+ .firstMatch+ }+ /// Flips a `Toggle` row, and asserts that it flipped. /// /// **Not `element.tap()`.** That taps the centre of the row, which in a@@ -172,12 +195,55 @@ final class CharacterExtractionUITests: XCTestCase { XCTAssertFalse( app.staticTexts["Ada keeps the lighthouse."].exists, "The unticked fact was not written")- // Q92: the struck alias was not installed either.+ // Q92: the struck alias was not installed either. The card lists the+ // aliases as chips beside the name (`work-detail-reading-redesign`+ // Q14), so what must not be there is a chip carrying that name. XCTAssertFalse(- app.staticTexts["Also Nightjar"].exists,+ aliasChip(named: "Nightjar").exists, "The struck alias did not become a match key") } + // MARK: - Req 5.2, Q10: the fact gutter's citation opens the note it cites++ /// The positive counterpart of the two negatives above: an alias the reader+ /// kept *is* a chip, and a fact grounded in a live note carries a link in+ /// its gutter that goes there.+ ///+ /// The link is the reason `characterFactRow` declares+ /// `accessibilityElement(children: .contain)`: the row's own identifier+ /// would otherwise be inherited by the button and no test could reach it.+ func testACitationInTheFactGutterOpensTheCitedNote() {+ openWorkDetail()+ openReview()+ // Ada carries the alias; Brede's fact is the one grounded in the+ // chapter note, so both are kept and each card is asked in turn.+ waitFor(app.anyElement("character-review-keep-ada"), "Ada can be kept").tap()+ waitFor(app.anyElement("character-review-keep-brede"), "Brede can be kept").tap()+ closeReview()++ waitFor(+ app.anyElement("work-detail-character"), "The kept characters appear on the page",+ timeout: 30)++ openPill(pill(named: "Ada"))+ waitFor(aliasChip(named: "Nightjar"), "The kept alias is a chip beside the name")++ openPill(pill(named: "Brede"))+ let citation = waitFor(+ app.buttons["work-detail-character-citation"],+ "The fact's gutter offers the note it cites")+ citation.tap()++ // Entry detail's section sits between the actions and the capture+ // details, so it needs a scroll on a short screen.+ if !app.anyElement("entry-detail-citing-character").waitForExistence(timeout: 5) {+ scrollEntryDetail(in: app)+ }+ waitFor(+ app.anyElement("entry-detail-citing-character"),+ "The citation opened the note it came from", timeout: 20)+ }+ // MARK: - Req 5.3, Q109: the review sheet is unreachable in edit mode /// The sheet's completion reload rebuilds the page's character drafts, so a
diff --git a/Asterism/AsterismUITests/WorkDetailActionsUITests.swift b/Asterism/AsterismUITests/WorkDetailActionsUITests.swiftindex e52cab1..92e732a 100644--- a/Asterism/AsterismUITests/WorkDetailActionsUITests.swift+++ b/Asterism/AsterismUITests/WorkDetailActionsUITests.swift@@ -254,6 +254,55 @@ final class WorkDetailActionsUITests: XCTestCase { "Back from a chapter entry does not skip the work it was opened from") } + // MARK: - The spine's two orders (`work-detail-reading-redesign`)++ /// Decision 1 and Q3: both orders are visible at once, the choice is one+ /// tap, and Newest is where every open of the screen starts.+ ///+ /// `seeded-composed` captures "Chapter 7 - Real Work" and then+ /// "Chapter 8 - Real Work", so 8 is the newer note *and* the higher chapter+ /// number — which is what makes the two orders tell each other apart here.+ func testTheSortControlReordersTheSpine() {+ openWorkDetail()++ scrollUntilPresent(+ chapterEntry(containing: "Chapter 8"), in: app, "The spine lists the work's notes")+ waitFor(chapterEntry(containing: "Chapter 7"), "…both of them")++ XCTAssertLessThan(+ chapterEntry(containing: "Chapter 8").frame.minY,+ chapterEntry(containing: "Chapter 7").frame.minY,+ "Newest first puts the later capture above the earlier one")++ let chapterOrder = app.buttons["work-detail-sort-chapter"]+ scrollUntilTappableAndTap(chapterOrder, in: app, "The spine offers the Chapter order")++ // The chosen segment says so, which is also how the test knows the tap+ // has landed before it measures where the rows ended up.+ let chosen = expectation(+ for: NSPredicate(format: "isSelected == true"), evaluatedWith: chapterOrder)+ XCTAssertEqual(+ XCTWaiter().wait(for: [chosen], timeout: 10), .completed,+ "The tapped order is the selected one")++ XCTAssertLessThan(+ chapterEntry(containing: "Chapter 7").frame.minY,+ chapterEntry(containing: "Chapter 8").frame.minY,+ "Chapter order puts the lower chapter number first")+ }++ /// One spine row, by the title inside its accessibility label. The rows all+ /// share the identifier `work-detail-entry`, so the label is what tells two+ /// of them apart.+ private func chapterEntry(containing fragment: String) -> XCUIElement {+ app.descendants(matching: .any)+ .matching(+ NSPredicate(+ format: "identifier == %@ AND label CONTAINS[c] %@",+ "work-detail-entry", fragment)+ ).firstMatch+ }+ // MARK: - Export (Req 2.4) func testExportFromViewModeReachesTheShareSheet() {
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 0a81f6d..bbe5f24 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,68 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed++- **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+ spine row, and the capsule-versus-menu rule (Decision 1, Q21); §3 records sans+ chapter rows (Q9), §5 the no-glow rule for dots and the selected pill (Q11),+ §10 the wrap-rather-than-clip fallbacks. `docs/asterism-design.md` §6 describes+ view mode as shipped and §7 replaces "no chapter-number parsing from titles"+ with Decision 2's guarded rule. `specs/polish-and-export/requirements.md` Reqs+ 5.1 (in part), 5.4 and 9.4 (in part) are marked superseded. Verified:+ `make test-core` (1,904 cases), `make test-quick` (831), and the six work-detail+ UI suites pass; a forced recompile of every changed Swift file shows no+ warnings at changed lines.++- **The work page reads as its notes (work-detail-reading-redesign, phase 3:+ View).** The work detail screen's view mode is rebuilt around the notes. The+ header loses its card and the three pulse cards: the full title, then one row+ of site glyph, hostname and URL identity, a `{n} notes ▲ {up} ▼ {down}` meta+ line and the link glyph, then the type and genre pills, then the work's own+ notes as a plain paragraph. Chapter notes render as a spine — a 1 pt rail+ through a rating dot per note (cyan ▲, violet ▼, outlined when unrated), the+ chapter key in mono beneath the dot, the row title with its chapter prefix+ trimmed and the date trailing, and the full note text with no line limit, no+ cards, no month headers. A `Newest | Chapter` capsule beside the "Chapter+ Notes" header reorders the spine per visit (Newest on every open); it wraps+ under the header at large text sizes and stacks its segments at the largest+ accessibility size (Decision 1, Q21). The selected character pill gets its own+ recipe (`ConstellationPillKind.selectedTypeTag`, chevron up); the expanded+ card shows the aliases as chips beside the serif name, the character note as+ a paragraph, a hairline, then the facts on the same gutter — a live citation's+ chapter number (or an arrow where the note has no number) is the tap target+ that opens the note, and generic or dangling facts show a dash with their+ caption. Every `work-detail-*` identifier the UI tests anchor on is kept; a+ new UI test covers the sort control. Header and meta-line wrapping, the fact+ gutter width and the dash glyph are recorded as Q18–Q20. Edit mode is+ unchanged.++- **Work detail spine — Newest and Chapter orders on the model (work-detail-reading-redesign,+ phase 2: Model).** Not yet user-visible. `WorkDetailModel` gains+ `ChapterSortOrder { newest, chapter }`, a `chapterRowsByChapter` order derived+ once per `load()` in the same statement as `presentation` (Q13), and+ `chapterRows(for:)`. Chapter order is Q4's: numbered notes by key ascending+ (`5.07` before `5.10`, `12 < 12.5`), ties oldest-first, then unnumbered notes+ oldest-first; the comparison is total, with the repository's position as the+ final tie-break. Newest is the repository's order untouched. Nothing is stored —+ the choice is per visit (Q3). Covered by four `WorkDetailModelTests` cases.++- **Work detail spine — chapter keys on the projection (work-detail-reading-redesign,+ phase 1: Core projection).** Not yet user-visible. `WorkChapterRow` now carries a+ derived `chapterKey` (a multi-part `ChapterKey` of `[Int]` components with the+ label as written, so `5-07`, `5.07` and `12.5` sort component-wise — Q15) and a+ `rowTitle` with a leading chapter prefix and the key's own number trimmed off+ (nil where the title is only that number — Q5). The key comes from the URL+ rule's `chapterSequence` first and from the title only behind a chapter prefix+ (`Chapter`, `Ch.`, `Episode`, `Ep`, `Part` — Decision 2), so "Book 2, Chapter 14"+ and "Top 10 Moments" stay unnumbered. `WorkCharacterFactRow.citedChapterKey`+ carries the same key for a live citation, built from the rows the read already+ produced. Both fields are defaulted so existing fixtures compile; nothing is+ persisted. Covered by `ChapterKeyDerivationTests` and two `WorkDetailReadTests`+ cases.+ ### Added - **Characters extracted from your notes, reviewed by you (character-extraction,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift b/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swiftnew file mode 100644index 0000000..480a769--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift@@ -0,0 +1,187 @@+import Foundation++// Where a chapter note sits in its work, derived on read (Decision 2, Q15).+//+// Nothing here is persisted: the key is a projection of what the site's rules+// produced, so re-teaching a site renumbers the spine without a migration. The+// derivation is pure and `nonisolated` — it runs inside the repository's locked+// read, and it is unit-tested without one.++/// A chapter's position, as a number that may have parts (Q15).+///+/// The Wandering Inn numbers chapters volume.chapter (`1.01`, `5.07`, and+/// `5-07` in its URLs), so a single integer would key every chapter of a volume+/// to the same number. `components` are the digit runs as written, parsed in+/// order; `label` is what the gutter draws — the runs joined by `.`, each run+/// exactly as the site wrote it (`007` stays `007`).+public struct ChapterKey: Sendable, Hashable, Comparable {+ public let components: [Int]+ public let label: String++ public init(components: [Int], label: String) {+ self.components = components+ self.label = label+ }++ /// Component-wise, shorter prefix first: `12 < 12.5 < 13`, `5.07 < 5.10 < 6.01`.+ ///+ /// The label breaks a tie between two keys that number the same chapter and+ /// write it differently (`007` and `7`), so the order is total and agrees+ /// with `==`.+ public static func < (lhs: ChapterKey, rhs: ChapterKey) -> Bool {+ for (left, right) in zip(lhs.components, rhs.components) where left != right {+ return left < right+ }+ if lhs.components.count != rhs.components.count {+ return lhs.components.count < rhs.components.count+ }+ return lhs.label < rhs.label+ }+}++/// Derives a chapter's key and the title the spine row shows beside it.+nonisolated enum ChapterKeyDerivation {++ /// The chapter prefixes a title may open with, longest first. A prefix that+ /// ends in a letter must stand as a whole word, so `Chapters 14` matches+ /// none of them.+ private static let titlePrefixes = ["chapter", "episode", "ch.", "ep.", "part", "ch", "ep"]++ /// One optional separator between a trimmed chapter number and the title+ /// 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.+ static func key(chapterSequence: String?, chapterTitle: String?) -> ChapterKey? {+ if let sequence = chapterSequence, let group = firstNumberGroup(in: Array(sequence)) {+ return group.key+ }+ if let title = chapterTitle, let match = prefixedNumberGroup(in: Array(title)) {+ return match.key+ }+ return nil+ }++ /// 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.+ static func rowTitle(displayTitle: String, key: ChapterKey?) -> String? {+ guard let key else { return displayTitle }+ let characters = Array(displayTitle)+ let start = skippingWhitespace(characters, from: 0)++ // A title that is only the key's number — a sequence-only chapter such+ // as "15", or The Wandering Inn's "5.07" — would say it twice.+ if let group = numberGroup(characters, from: start), group.key.components == key.components,+ skippingWhitespace(characters, from: group.end) == characters.count+ {+ return nil+ }++ guard let match = prefixedNumberGroup(in: characters),+ match.key.components == key.components+ else { return displayTitle }++ var index = skippingWhitespace(characters, from: match.end)+ if index < characters.count, separators.contains(characters[index]),+ index + 1 == characters.count || characters[index + 1].isWhitespace+ {+ index += 1+ }+ let remainder = String(characters[index...])+ .trimmingCharacters(in: .whitespacesAndNewlines)+ // "Chapter 14" is left as it is: an empty row title is not a title.+ return remainder.isEmpty ? displayTitle : remainder+ }++ // MARK: - Number groups++ /// 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`.+ private static func numberGroup(+ _ characters: [Character], from start: Int+ ) -> (key: ChapterKey, end: Int)? {+ var index = start+ var components: [Int] = []+ var written: [String] = []+ while true {+ var run = ""+ while index < characters.count, isASCIIDigit(characters[index]) {+ run.append(characters[index])+ index += 1+ }+ guard !run.isEmpty, let value = Int(run) else { return nil }+ components.append(value)+ written.append(run)+ // A separator continues the group only when a digit follows it.+ guard index + 1 < characters.count, characters[index] == "." || characters[index] == "-",+ isASCIIDigit(characters[index + 1])+ else { break }+ index += 1+ }+ return (ChapterKey(components: components, label: written.joined(separator: ".")), index)+ }++ /// The first number group anywhere in the characters.+ private static func firstNumberGroup(in characters: [Character]) -> (key: ChapterKey, end: Int)?+ {+ guard let start = characters.firstIndex(where: isASCIIDigit) else { return nil }+ return numberGroup(characters, from: start)+ }++ /// The number group a chapter prefix introduces, where the characters open+ /// with one (Decision 2).+ private static func prefixedNumberGroup(+ in characters: [Character]+ ) -> (key: ChapterKey, end: Int)? {+ let start = skippingWhitespace(characters, from: 0)+ for prefix in titlePrefixes {+ guard let after = matching(prefix, in: characters, from: start) else { continue }+ if let match = numberGroup(characters, from: skippingWhitespace(characters, from: after))+ {+ return match+ }+ }+ return nil+ }++ /// The index one past `prefix` where the characters carry it at `start`,+ /// case-insensitively and — for a prefix ending in a letter — as a whole+ /// word.+ private static func matching(+ _ prefix: String, in characters: [Character], from start: Int+ ) -> Int? {+ var index = start+ for character in prefix {+ guard index < characters.count,+ characters[index].lowercased() == String(character)+ else { return nil }+ index += 1+ }+ // A prefix ending in a letter must stand as a whole word: `Chapters 14`+ // is not a numbered chapter. A digit may follow it directly — the+ // whitespace between the two is optional (`Chapter14`).+ if let last = prefix.last, last.isLetter, index < characters.count,+ characters[index].isLetter+ {+ return nil+ }+ return index+ }++ // MARK: - Scanning++ private static func skippingWhitespace(_ characters: [Character], from start: Int) -> Int {+ var index = start+ while index < characters.count, characters[index].isWhitespace { index += 1 }+ return index+ }++ /// Only `0`–`9`. A title numbered in Arabic-Indic or fullwidth digits is+ /// left unnumbered rather than parsed by a rule nobody taught.+ private static func isASCIIDigit(_ character: Character) -> Bool {+ character.isASCII && character.isNumber+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swiftindex 582f1cd..eae1433 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift@@ -31,15 +31,28 @@ public struct WorkChapterRow: Identifiable, Sendable, Equatable { public let note: String public let rating: Rating? public let lastSharedAt: Date+ /// Where the note sits in the work, for the spine gutter and the Chapter+ /// order (Decision 2, Q15). Nil where neither the URL rule's sequence nor a+ /// prefixed title numbers it.+ public let chapterKey: 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 —+ /// a fixture, a preview — carries the same row title a read would give it,+ /// and the key and the title it is trimmed against cannot come apart.+ public let rowTitle: String? public init(- id: UUID, displayTitle: String, note: String, rating: Rating?, lastSharedAt: Date+ id: UUID, displayTitle: String, note: String, rating: Rating?, lastSharedAt: Date,+ chapterKey: ChapterKey? = nil ) { self.id = id self.displayTitle = displayTitle self.note = note self.rating = rating self.lastSharedAt = lastSharedAt+ self.chapterKey = chapterKey+ self.rowTitle = ChapterKeyDerivation.rowTitle(displayTitle: displayTitle, key: chapterKey) } } @@ -104,19 +117,27 @@ extension LibraryRepository { var sites = SiteLookupCache() let rows = try entries.map { entry in- WorkChapterRow(+ // Req 8.12's chapter resolution, as Recent and entry detail+ // resolve it: the chapter title, else the URL-rule-derived+ // sequence — a chapter the rule numbered but did not name is+ // still a parsed chapter — else the cleaned capture title.+ let displayTitle = try entry.chapterTitle ?? entry.chapterSequence+ ?? Self.displayTitle(+ for: entry.captureTitle, hostname: entry.hostname, sites: &sites,+ context: context)+ // Derived here rather than in the row that draws it, for the+ // reason the title is: the spine's order and the citation+ // gutter both read this number, and two derivations of it would+ // be two answers.+ let chapterKey = ChapterKeyDerivation.key(+ chapterSequence: entry.chapterSequence, chapterTitle: entry.chapterTitle)+ return WorkChapterRow( id: entry.id,- // Req 8.12's chapter resolution, as Recent and entry detail- // resolve it: the chapter title, else the URL-rule-derived- // sequence — a chapter the rule numbered but did not name is- // still a parsed chapter — else the cleaned capture title.- displayTitle: try entry.chapterTitle ?? entry.chapterSequence- ?? Self.displayTitle(- for: entry.captureTitle, hostname: entry.hostname, sites: &sites,- context: context),+ displayTitle: displayTitle, note: entry.note, rating: entry.rating,- lastSharedAt: entry.lastSharedAt)+ lastSharedAt: entry.lastSharedAt,+ chapterKey: chapterKey) } // Q88's two inputs, from this same context: where each live entry@@ -143,6 +164,11 @@ extension LibraryRepository { return (entry.id, cleanedKey == workTitleKey ? nil : row.displayTitle) }, uniquingKeysWith: { first, _ in first }) let titles = titlesByEntry.compactMapValues { $0 }+ // The same keys the chapter rows carry, so a citation's gutter and+ // its note's gutter draw one number.+ let keys = Dictionary(+ rows.compactMap { row in row.chapterKey.map { (row.id, $0) } },+ uniquingKeysWith: { first, _ in first }) let dates = Dictionary( entries.map { ($0.id, $0.lastSharedAt) }, uniquingKeysWith: { first, _ in first }) // From the group's own rows, not a whole-table fetch filtered down@@ -161,7 +187,7 @@ extension LibraryRepository { chapterRows: rows, characters: Self.characterPresentations( Self.characterGroups(characterRows),- captureOrder: captureOrder, titles: titles, dates: dates),+ captureOrder: captureOrder, titles: titles, dates: dates, keys: keys), captureOrder: captureOrder) } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swiftindex a3fd3ed..c5a80b6 100644--- a/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift@@ -33,6 +33,11 @@ public struct WorkCharacterFactRow: Identifiable, Sendable, Equatable { /// work's own page a citation labelled with the work's title says nothing, /// but "Noted 12 Aug" always identifies the note. public let citationDate: Date?+ /// Where the cited note sits in the work — the same key its chapter row+ /// carries, so the citation gutter draws the number the spine draws. Nil for+ /// a generic-notes citation, for a dangling one, and for a live note the+ /// rules do not number; the caption stands in then (Q10, Q12).+ public let citedChapterKey: ChapterKey? /// Req 3.5: the cited source no longer exists. A tolerated state, never an /// integrity error — the statement and the quote are still the reader's. public let isDangling: Bool@@ -43,8 +48,10 @@ public struct WorkCharacterFactRow: Identifiable, Sendable, Equatable { public init( id: String, statement: String, quote: String, source: SourceRef, citedEntryID: UUID?, citationTitle: String?, citationDate: Date? = nil,+ citedChapterKey: ChapterKey? = nil, isDangling: Bool, fact: CharacterFact ) {+ self.citedChapterKey = citedChapterKey self.id = id self.statement = statement self.quote = quote@@ -102,14 +109,16 @@ extension LibraryRepository { /// The work's characters as the page draws them, in name order. ///- /// `captureOrder` maps a live entry's UUID to its position oldest-first, and- /// `titles` to what that entry is called. Both come from the same locked- /// read as the characters themselves.+ /// `captureOrder` maps a live entry's UUID to its position oldest-first,+ /// `titles` to what that entry is called, `dates` to when it was captured,+ /// and `keys` to its chapter key where the rules number it. All four come+ /// from the same locked read as the characters themselves. internal static func characterPresentations( _ groups: [UUID: CharacterGroup], captureOrder: [UUID: Int], titles: [UUID: String],- dates: [UUID: Date] = [:]+ dates: [UUID: Date] = [:],+ keys: [UUID: ChapterKey] = [:] ) -> [WorkCharacterPresentation] { groups.values .map { group in@@ -121,7 +130,8 @@ extension LibraryRepository { aliases: content.aliases, nameKey: group.carrier.nameKey, facts: factRows(- content.facts, captureOrder: captureOrder, titles: titles, dates: dates),+ content.facts, captureOrder: captureOrder, titles: titles, dates: dates,+ keys: keys), isTorn: group.isTorn, rowCount: group.rows.count, editBasis: CharacterEditBasis(characterID: group.id, content: content))@@ -139,7 +149,8 @@ extension LibraryRepository { _ facts: [CharacterFact], captureOrder: [UUID: Int], titles: [UUID: String],- dates: [UUID: Date] = [:]+ dates: [UUID: Date] = [:],+ keys: [UUID: ChapterKey] = [:] ) -> [WorkCharacterFactRow] { facts .map { fact -> WorkCharacterFactRow in@@ -157,6 +168,7 @@ extension LibraryRepository { citedEntryID: live ? entryID : nil, citationTitle: live ? titles[entryID] : nil, citationDate: live ? dates[entryID] : nil,+ citedChapterKey: live ? keys[entryID] : nil, isDangling: !live, fact: fact) } }
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swiftindex c4cb305..ec07dc1 100644--- a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift+++ b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift@@ -57,6 +57,11 @@ public enum ConstellationPillKind: Sendable { case count /// Work *type* tag: violet tint and border (§7). case typeTag+ /// The type-tag recipe lifted, for a pill that is currently *selected* —+ /// work detail's expanded cast pill. A stronger violet fill and border, and+ /// no glow: §5 puts glow on identity and primary action, and a selected pill+ /// is neither (`work-detail-reading-redesign` Q11, Q14).+ case selectedTypeTag /// A work type that is no longer offered — removed from the list, or a value /// written by a build that did not have the list. The same violet recipe, /// knocked down: it is still the type it always was, just not a choice any@@ -71,7 +76,7 @@ public enum ConstellationPillKind: Sendable { // the raw fill hue fails contrast as text on light. case .attention: AsterismColors.amberText case .count: AsterismColors.cyan- case .typeTag, .dimmedTypeTag: AsterismColors.violet+ case .typeTag, .dimmedTypeTag, .selectedTypeTag: AsterismColors.violet case .genreTag: AsterismColors.secondaryText } }@@ -81,6 +86,7 @@ public enum ConstellationPillKind: Sendable { case .attention: AsterismColors.amber.opacity(0.18) case .count: AsterismColors.cyan.opacity(0.12) case .typeTag, .dimmedTypeTag: AsterismColors.violet.opacity(0.12)+ case .selectedTypeTag: AsterismColors.violet.opacity(0.26) case .genreTag: AsterismColors.cardFill } }@@ -90,6 +96,7 @@ public enum ConstellationPillKind: Sendable { case .attention: AsterismColors.attentionBorder case .count: .clear case .typeTag, .dimmedTypeTag: AsterismColors.violet.opacity(0.35)+ case .selectedTypeTag: AsterismColors.violet.opacity(0.6) case .genreTag: AsterismColors.cardBorder } }@@ -98,7 +105,7 @@ public enum ConstellationPillKind: Sendable { switch self { case .attention: AsterismLayout.buttonRadius case .count: AsterismLayout.urlChipRadius- case .typeTag, .dimmedTypeTag, .genreTag: AsterismLayout.tagRadius+ case .typeTag, .dimmedTypeTag, .selectedTypeTag, .genreTag: AsterismLayout.tagRadius } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swiftnew file mode 100644index 0000000..48061cd--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swift@@ -0,0 +1,175 @@+import Foundation+import Testing++@testable import AsterismCore++// The chapter key and the spine's row title (Decision 2, Q5, Q15): what the+// gutter numbers a note, and what is left to say beside it once the number is+// drawn.++@Suite("Chapter key derivation")+struct ChapterKeyDerivationTests {++ private static func key(sequence: String? = nil, title: String? = nil) -> ChapterKey? {+ ChapterKeyDerivation.key(chapterSequence: sequence, chapterTitle: title)+ }++ private static func key(_ components: [Int], _ label: String) -> ChapterKey {+ ChapterKey(components: components, label: label)+ }++ // MARK: - The key++ /// Q1: the URL rule is the taught, site-specific source; the title is only+ /// the fallback.+ @Test("The URL rule's sequence wins over a numbered title")+ func sequenceWinsOverTitle() {+ #expect(Self.key(sequence: "341", title: "Chapter 7") == Self.key([341], "341"))+ #expect(Self.key(sequence: "chapter-341") == Self.key([341], "341"))+ }++ @Test(+ "A title numbers a chapter only behind a chapter prefix",+ arguments: [+ "Chapter 14", "chapter 14", "CHAPTER 14", "Chapter14", " Chapter 14",+ "Ch. 14", "Ch.14", "Ch 14", "Episode 14", "Ep. 14", "Ep 14", "Part 14",+ "Chapter 14: The Long Way",+ ])+ func prefixedTitlesKey(_ title: String) {+ #expect(Self.key(title: title) == Self.key([14], "14"))+ }++ /// Decision 2: an unguarded first-digit-run rule would key these 2, 10, 2+ /// and nothing, and each wrong key reorders the spine.+ @Test(+ "Every other title yields no key",+ arguments: [+ "Book 2, Chapter 14", "Top 10 Moments", "2nd Interlude", "Interlude \u{2013} Pisces",+ "Chapters 14", "Chapter", "Parting Ways 3", "The Long Way",+ ])+ func unprefixedTitlesDoNotKey(_ title: String) {+ #expect(Self.key(title: title) == nil)+ }++ /// A run that does not fit `Int` is not a chapter number anyone wrote.+ @Test("A digit run too large for Int yields no key")+ func overflowYieldsNoKey() {+ #expect(Self.key(sequence: "99999999999999999999") == nil)+ #expect(Self.key(title: "Chapter 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")+ func nonASCIIDigitsYieldNoKey() {+ #expect(Self.key(sequence: "\u{0661}\u{0665}") == nil)+ #expect(Self.key(sequence: "\u{FF11}\u{FF15}") == nil)+ #expect(Self.key(title: "Chapter \u{0661}\u{0665}") == nil)+ }++ /// Q15: The Wandering Inn numbers volume.chapter, and its URLs write the+ /// same number with a hyphen. Both key alike, and the label is as written.+ @Test(+ "A number group's runs are its components, and its label is as written",+ arguments: [+ ("5-07", [5, 7], "5.07"),+ ("5.07", [5, 7], "5.07"),+ ("1.01", [1, 1], "1.01"),+ ("12.5", [12, 5], "12.5"),+ ("007", [7], "007"),+ ("341", [341], "341"),+ ])+ func multiPartKeys(_ sequence: String, _ components: [Int], _ label: String) {+ #expect(Self.key(sequence: sequence) == Self.key(components, label))+ }++ @Test("Keys compare component-wise, shorter prefix first")+ func keysOrderComponentWise() {+ let ascending = ["12", "12.5", "13"].compactMap { Self.key(sequence: $0) }+ #expect(ascending.shuffled().sorted() == ascending)+ #expect(Self.key(sequence: "5.07")! < Self.key(sequence: "5.10")!)+ #expect(Self.key(sequence: "5.10")! < Self.key(sequence: "6.01")!)+ }++ /// Q17: two keys that number the same chapter and write it differently are+ /// not the same key — the gutter draws the label, so `007` and `7` are two+ /// notes. The label breaks the tie, which is what keeps the order total.+ @Test("Two spellings of one number are distinct keys, ordered by their label")+ func labelBreaksTheTie() {+ let padded = Self.key([7], "007")+ let bare = Self.key([7], "7")+ #expect(padded != bare)+ #expect(padded < bare)+ #expect([bare, padded].sorted() == [padded, bare])+ }++ // MARK: - The row title++ /// Q5: the gutter already draws the number, so "15 · 15" would say it twice.+ @Test("A title that is only the key's number gives no row title")+ func numberOnlyTitlesGiveNoRowTitle() {+ #expect(ChapterKeyDerivation.rowTitle(displayTitle: "15", key: Self.key([15], "15")) == nil)+ #expect(+ ChapterKeyDerivation.rowTitle(displayTitle: "5.07", key: Self.key([5, 7], "5.07"))+ == nil)+ #expect(+ ChapterKeyDerivation.rowTitle(displayTitle: "5-07", key: Self.key([5, 7], "5.07"))+ == nil)+ }++ @Test(+ "A leading prefix, the key's number and one separator are trimmed",+ arguments: [+ ("Chapter 14: The Long Way", [14], "The Long Way"),+ ("Chapter 14 - The Long Way", [14], "The Long Way"),+ ("Chapter 14 \u{2013} The Long Way", [14], "The Long Way"),+ ("Chapter 14 \u{2014} The Long Way", [14], "The Long Way"),+ ("Ch. 14. The Long Way", [14], "The Long Way"),+ ("Chapter 14 The Long Way", [14], "The Long Way"),+ // The number is compared by components, so a sequence-derived `7`+ // still trims a title that writes it `007`.+ ("Chapter 007: The Long Way", [7], "The Long Way"),+ ("Chapter 3.1: The Long Way", [3, 1], "The Long Way"),+ ])+ func prefixedTitlesAreTrimmed(_ title: String, _ components: [Int], _ expected: String) {+ let label = components.map(String.init).joined(separator: ".")+ #expect(+ ChapterKeyDerivation.rowTitle(+ displayTitle: title, key: Self.key(components, label)) == expected)+ }++ /// The trim fires only where the title's own number is the key's, so a+ /// "Part 2" second half never loses its number, and `Chapters 14` is not a+ /// prefixed title at all.+ @Test(+ "A title that does not open with the key's number is left alone",+ arguments: [+ ("Chapters 14", [14]),+ ("Book 2, Chapter 14", [14]),+ ("Part 2", [14]),+ ("The Long Way", [14]),+ ])+ func unmatchedTitlesAreLeftAlone(_ title: String, _ components: [Int]) {+ #expect(+ ChapterKeyDerivation.rowTitle(displayTitle: title, key: Self.key(components, "14"))+ == title)+ }++ /// An empty row title is not a title: the reader keeps what the site wrote.+ @Test("A title with nothing left after the trim is kept whole")+ func emptyRemainderKeepsTheTitle() {+ #expect(+ ChapterKeyDerivation.rowTitle(displayTitle: "Chapter 14", key: Self.key([14], "14"))+ == "Chapter 14")+ #expect(+ ChapterKeyDerivation.rowTitle(displayTitle: "Ch. 14 -", key: Self.key([14], "14"))+ == "Ch. 14 -")+ }++ @Test("An unnumbered note keeps its whole title")+ func unnumberedNotesKeepTheirTitle() {+ #expect(+ ChapterKeyDerivation.rowTitle(displayTitle: "Interlude \u{2013} Pisces", key: nil)+ == "Interlude \u{2013} Pisces")+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swiftindex a5f2b8a..3fccad3 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift@@ -153,6 +153,117 @@ struct WorkDetailReadTests { #expect(detail.chapterRows[0].lastSharedAt == day3) } + @Test("Chapter rows carry the derived key and the row title (Decision 2, Q5, Q15)")+ func chapterRowsCarryKeysAndRowTitles() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let sequenceOnly = UUID()+ let titledChapter = UUID()+ let trimmedAgainstSequence = UUID()+ let unnumbered = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+ entries: [+ // The URL rule's own number, written volume-hyphen-chapter.+ M5SeedEntry(+ id: sequenceOnly, captureTitle: "A Serial", hostname: "example.com",+ path: "one", chapterSequence: "5-07", workID: workID),+ // No rule, but the site opens its titles with the number.+ M5SeedEntry(+ id: titledChapter, captureTitle: "A Serial", hostname: "example.com",+ path: "two", chapterTitle: "Chapter 12: The Long Way", workID: workID),+ // The sequence keys it; the title writes the same number wider.+ M5SeedEntry(+ id: trimmedAgainstSequence, captureTitle: "A Serial", hostname: "example.com",+ path: "three", chapterTitle: "Chapter 007 \u{2013} The Fall",+ chapterSequence: "7", workID: workID),+ M5SeedEntry(+ id: unnumbered, captureTitle: "A Serial", hostname: "example.com",+ path: "four", chapterTitle: "Interlude \u{2013} Pisces", workID: workID),+ ])++ let detail = try await fixture.repository.workDetail(id: workID)+ let byEntry = Dictionary(+ detail.chapterRows.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })++ #expect(byEntry[sequenceOnly]?.chapterKey == ChapterKey(components: [5, 7], label: "5.07"))+ // The gutter draws the number, so the row has nothing left to title.+ #expect(byEntry[sequenceOnly]?.rowTitle == nil)++ #expect(byEntry[titledChapter]?.chapterKey == ChapterKey(components: [12], label: "12"))+ #expect(byEntry[titledChapter]?.rowTitle == "The Long Way")++ #expect(+ byEntry[trimmedAgainstSequence]?.chapterKey == ChapterKey(components: [7], label: "7"))+ #expect(byEntry[trimmedAgainstSequence]?.rowTitle == "The Fall")++ #expect(byEntry[unnumbered]?.chapterKey == nil)+ #expect(byEntry[unnumbered]?.rowTitle == "Interlude \u{2013} Pisces")+ // `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")+ }++ @Test("A live citation carries its chapter key; a dangling one carries none")+ func citationsCarryChapterKeys() async throws {+ let fixture = try await M5Fixture()+ let workID = UUID()+ let numbered = UUID()+ let unnumbered = UUID()+ let gone = UUID()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "example.com")],+ works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+ entries: [+ M5SeedEntry(+ id: numbered, captureTitle: "A Serial", hostname: "example.com", path: "one",+ chapterSequence: "12", workID: workID),+ M5SeedEntry(+ id: unnumbered, captureTitle: "A Serial", hostname: "example.com", path: "two",+ chapterTitle: "Interlude \u{2013} Pisces", workID: workID),+ ],+ characters: [+ M5SeedCharacter(+ id: UUID(), name: "Hanna", nameKey: "hanna",+ facts: [+ CharacterFact(+ statement: "Rows the tender", quote: "Hanna rows the tender",+ nameKey: "hanna", source: .entry(numbered)),+ CharacterFact(+ statement: "Keeps the lamp", quote: "Hanna keeps the lamp",+ nameKey: "hanna", source: .entry(unnumbered)),+ // The cited note is not this work's — Req 3.5's+ // tolerated dangling citation.+ CharacterFact(+ statement: "Stays behind", quote: "Hanna stays behind",+ nameKey: "hanna", source: .entry(gone)),+ CharacterFact(+ statement: "Sails at dawn", quote: "Hanna sails at dawn",+ nameKey: "hanna", source: .genericNotes),+ ],+ workID: workID)+ ])++ let detail = try await fixture.repository.workDetail(id: workID)+ let facts = try #require(detail.characters.first).facts+ let byStatement = Dictionary(+ facts.map { ($0.statement, $0) }, uniquingKeysWith: { first, _ in first })++ // The citation gutter draws the number the note's own row draws.+ #expect(+ byStatement["Rows the tender"]?.citedChapterKey+ == ChapterKey(components: [12], label: "12"))+ // Live but unnumbered (Q10): the arrow, and the caption that says which+ // note it is.+ #expect(byStatement["Keeps the lamp"]?.citedChapterKey == nil)+ #expect(byStatement["Keeps the lamp"]?.citedEntryID == unnumbered)+ // Dangling and generic-notes citations navigate to no chapter at all.+ #expect(byStatement["Stays behind"]?.isDangling == true)+ #expect(byStatement["Stays behind"]?.citedChapterKey == nil)+ #expect(byStatement["Sails at dawn"]?.citedChapterKey == nil)+ }+ @Test("Open-last-noted points at the newest lastSharedAt entry's raw URL (5.3)") func lastNotedURL() async throws { let fixture = try await M5Fixture()
diff --git a/Packages/AsterismCore/Tests/ConstellationKitTests/ConstellationPillKindTests.swift b/Packages/AsterismCore/Tests/ConstellationKitTests/ConstellationPillKindTests.swiftnew file mode 100644index 0000000..aec6362--- /dev/null+++ b/Packages/AsterismCore/Tests/ConstellationKitTests/ConstellationPillKindTests.swift@@ -0,0 +1,28 @@+import SwiftUI+import Testing++@testable import ConstellationKit++/// The pill recipes are the design language written down once (Q14), so the+/// selected cast pill's treatment is pinned here rather than at its one call+/// site — the next surface that needs a selected pill gets the same one.+@Suite("ConstellationPillKind treatments")+struct ConstellationPillKindTests {+ /// `work-detail-reading-redesign` Q11/Q14: the selected pill is the type tag+ /// lifted — same violet text, a .26 fill against the unselected .12, and a+ /// .6 border against .35.+ @Test("the selected type tag lifts the type tag's violet")+ func selectedTypeTagLiftsTheTypeTag() {+ #expect(ConstellationPillKind.selectedTypeTag.foreground == AsterismColors.violet)+ #expect(ConstellationPillKind.selectedTypeTag.fill == AsterismColors.violet.opacity(0.26))+ #expect(ConstellationPillKind.selectedTypeTag.border == AsterismColors.violet.opacity(0.6))+ }++ /// It is a *tag*, so it keeps the tag radius and is not knocked down the way+ /// a retired type is.+ @Test("the selected type tag keeps the tag shape and full opacity")+ func selectedTypeTagKeepsTheTagShape() {+ #expect(ConstellationPillKind.selectedTypeTag.cornerRadius == AsterismLayout.tagRadius)+ #expect(ConstellationPillKind.selectedTypeTag.opacity == 1)+ }+}
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex 819f46c..4bef688 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -308,11 +308,13 @@ Two modes (`specs/polish-and-export/`, Decision 5). **View mode** is the default View mode, top to bottom: -1. **Header**: the work's full title as a wrapping serif heading, then glyph, site + URL-ID, a `link` glyph opening the work URL, and the tag row (type + genre tags) as read-only chips. The title is also the **navigation title**, in its `.inline` collapsed form — a large title cannot wrap, so the bar carries the short version and the header carries the whole of it (Q58).-2. **Rating pulse**: notes count, ▲ count, ▼ count. Counts only.-3. **Open last noted chapter** — primary action. Labelled precisely: it opens the newest entry's URL ("back to where I was"), not the latest published chapter.-4. **Notes on this work** — generic free-form notes, shown where non-empty.-5. **Chapter notes** — entries newest-first (by lastSharedAt), dense rows: rating glyph, chapter title, note preview, date. **Tapping a row opens that entry's detail screen** (Q56).+View mode is a reading surface, not a summary: the notes are the content and the chrome above them is folded down to what names the work (`specs/work-detail-reading-redesign/`).++1. **Header**, on no card at all: the work's full title as a wrapping serif heading; then one row of glyph, site + URL-ID, the **meta line** — `{n} notes ▲ {up} ▼ {down}`, counts only, no drill-down — and a `link` glyph opening the work URL; then the tag row (type + genre tags) as read-only chips. The title is also the **navigation title**, in its `.inline` collapsed form — a large title cannot wrap, so the bar carries the short version and the header carries the whole of it (Q58).+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). Its toolbar carries **Export** (the one read action) and the pencil. @@ -342,7 +344,9 @@ Actions: ## 7. Entry ordering -Within a work and in the feed, entries order by lastSharedAt. No chapter-number parsing from titles — unreliable (interludes, "Chapter 12.5", rewrites) — and for serial reading capture order ≈ chapter order anyway. Work pages display newest-first; exports render oldest-first (§8).+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. --- @@ -408,7 +412,7 @@ Thumbnails, when they arrive in v2, are stored as externally-stored `Data` or a - Teach mode: chip cycling, end-anchored derivation with contiguous work ranges, live preview, retroactive re-parse, URL-identity card with backfill, confirmed work-URL step, articles mode with junk-strip, re-teach with both scopes (§4.7). - Recent feed (lastSharedAt order), day grouping, inbox banner/filter (unparsed + review-duplicate), inline Teach. - Works list, unattached group, empty-work sinking, New Work.-- Work detail: rating pulse, open-last-noted, generic notes, chapter rows that open their entries; title, type and genre-tag editing behind an explicit edit mode.+- Work detail: header meta line, open-last-noted, generic notes, the chapter-notes spine with its Newest/Chapter order, rows that open their entries; title, type and genre-tag editing behind an explicit edit mode. - Entry detail: chapter title card, Re-parse, Move to… with New Work and Leave unattached. (Per-field provenance is a diagnostics surface, not a reader-facing one — Q52.) - Merge with defined semantics; deletion behaviours incl. intentional-unattach on detach. - Duplicate handling: identical auto-collapse, divergent review sheet.@@ -460,7 +464,7 @@ Scope lives here; sequencing and per-item briefs live in | URL-ID identity, title as display value | Title-keyed identity | "(stubbed)" and other title churn; late rules backfill with manual-merge flags. | | Human-confirmed workURL | Inferred path truncation | Truncation fails for query-param identities (incl. Webtoons) and non-terminal paths. Confirmed or absent. | | Manual Merge / Move to… with New Work & Leave unattached | Duplicate detection / clever grouping | Rare events, human resolution, complete recovery path. |-| Capture-order entries | Chapter-number parsing | Parsing is unreliable; orders coincide in practice. |+| Capture-order entries, with derived chapter keys | Chapter-number parsing from any digit in a title | Unguarded parsing is unreliable; keys come from the URL rule's sequence or a prefixed title only, and are derived on read (§7, `specs/work-detail-reading-redesign/` Decision 2). | | Markdown export = document; JSON backup = backup, self-validated | "CloudKit is the backup" | Sync propagates mistakes; unverified insurance isn't insurance. | | App owns migrations; extension checks marker pre-container | Extension migrates / capture queue in v1 | Opening the container can itself trigger migration; queue deferred to public release. | | Capture-time completeness | "Schema-complete" speculative fields | Raw inputs, evidence, and timestamps are unrecoverable; empty future columns are not. |
diff --git a/docs/asterism-style-guide.md b/docs/asterism-style-guide.mdindex 1bff40a..ca05a35 100644--- a/docs/asterism-style-guide.md+++ b/docs/asterism-style-guide.md@@ -44,12 +44,12 @@ Amber marks a state the reader has to clear — teaching/unparsed, duplicate rev | Use | Face | Notes | |---|---|---| | Work titles, nav large titles, screen titles, entry-detail chapter heading | **New York (SF Serif)** — the mockups use Newsreader as the web stand-in | Weight ~medium (550), tight tracking on large sizes. Nav titles ~33 pt, work title in detail ~21 pt, row work names ~15 pt |-| Everything else (chapter lines, notes, labels, buttons) | **SF Pro Text** | System defaults |+| Everything else (chapter lines, notes, labels, buttons) | **SF Pro Text** | System defaults. Work detail's chapter-note row titles are 13 pt semibold (`.footnote.weight(.semibold)`) | | Provenance disclosure, URL chips | **SF Mono** | 11 pt, dim color, `word-break` on URLs | Section headers: 11 pt, SF Pro bold, uppercase, letter-spacing ~0.1–0.12 em, dim color, prefixed with a small ✦ in cyan (first section) or violet (subsequent) — see the day headers in *[Recent dark]*. -Serif is for **titles of works and screens only** — never for notes, buttons, or metadata. The serif/sans contrast is the identity; don't dilute it.+Serif is for **titles of works and screens only** — never for notes, buttons, or metadata. The serif/sans contrast is the identity; don't dilute it. A *chapter* name is not a work title: work detail's spine rows are sans (`specs/work-detail-reading-redesign/`, Q9), and the serif on that screen is the work's own heading and the expanded character's name. ## 4. Materials (Liquid Glass) @@ -73,7 +73,7 @@ Glow marks *identity and primary action*, nothing else: - Active tab icon: `drop-shadow(0 0 8px cyan @ .7)` (dark only). - Selected teach chip: `0 0 18px` at .2 in the chip color. -No glow on: text, cards, dim glyphs (`?`, ✎), rating triangles, banners (banner gets at most a whisper, ≤.08).+No glow on: text, cards, dim glyphs (`?`, ✎), rating triangles, the spine's rating dots and the selected cast pill on work detail (`specs/work-detail-reading-redesign/`, Q11 — a selected state is neither identity nor a primary action), banners (banner gets at most a whisper, ≤.08). ## 6. Shape @@ -94,7 +94,10 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field - **Tags**: type tag = violet tint/border; genre tags = neutral card recipe. 10.5 pt, weight 650. - **Count pill** (works list): cyan text on cyan .12 fill, radius 12. - **Unattached notes group**: dashed border (`rgba(170,200,255,.18)` dark), lower fill, dim ✎ glyph, neutral count pill. *[Works dark]*.-- **Rating pulse** (work detail): three equal cards; counts 15 pt/800; ▲ count cyan, ▼ count violet, notes count neutral.+- **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.+- **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.)* ## 8. Iconography@@ -115,6 +118,7 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field - Respect Reduce Transparency (fall back to opaque `#12162a` dark / `#f2f3f8` light card fills) and Reduce Motion (nothing extra to do — motion is already minimal). - Hit targets ≥ 44 pt even where visuals are smaller (Teach pill, tags, chips). - Dynamic Type: serif titles scale with the system; truncate single-line work/chapter names with ellipsis rather than wrapping in rows. **Rows only** — a detail screen's own heading wraps: work detail's header title is multi-line with no line limit, and the navigation bar carries the collapsed, truncating form of it (Q58).+- Where a row cannot hold everything on one line at the accessibility sizes, the trailing element moves to its own line rather than clip, hyphenate, or squeeze its neighbour: work detail's meta line drops under the site identity (`specs/work-detail-reading-redesign/`, Q18), and its sort capsule drops under the section header and then stacks its two segments (Q21). ## 11. Anti-rules
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 6d3d3ea..a40e3ba 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -23,6 +23,7 @@ | [Stats Page](#stats-page) | 2026-08-16 | Done — all 16 tasks implemented; Reqs 7.1, 7.2 and 7.5's visual half await the reader's own device check (`prerequisites.md`) | A third tab (T-2192) with two lifetime totals, a bar graph of reading activity over a selected period, and a per-work breakdown of a selected day. Entirely app-layer (Decision 5): notes, dates and work attribution come from `recentPresentation.allRows`, the works total from `worksSnapshot` — `AsterismCore` is untouched. The graph counts **first captures**, so re-reads are deliberately invisible (Decision 1). Supersedes in part `polish-and-export` Reqs 9.3 and 11.2, design §5, and style guide §7/§8. | | [Rule Suggestion](#rule-suggestion) | 2026-08-17 | Done | v2 plan item 3 (T-2156). The on-device Foundation Model proposes a title rule and URL rule for an untaught hostname from its captures; the composed teaching editor opens pre-filled with a "Suggested" marker and the reader saves as normal. Suggestions are precomputed in the background, verified against every capture on the hostname before they are shown, never written without a save, and their absence — model unavailable, verification failed, not finished — leaves the editor exactly as today. First `FoundationModels` use in the tree; adds an `AsterismIntelligence` package target the extension never links. No schema, migration or archive change. | | [Character Extraction](#character-extraction) | 2026-08-19 | Done | v2 plan item 4 (T-2229). The on-device model reads a work's notes and proposes characters — each fact a verbatim quote citing its source — held until the reader accepts them in a per-candidate, per-fact review; accepted characters are fully reader-editable (and combinable) and live in a new `Character` entity. New schema V7, new archive generation 6/7, CloudKit-synced with the full torn/duplicate machinery. Prototype over a real archive gated the design (Decision 3) and set the schema: aliases yes, confidence no. |+| [Work Detail Reading Redesign](#work-detail-reading-redesign) | 2026-08-22 | Done | Smolspec. Makes the notes the content of the work detail screen: the header folds into title, site line, a `{n} notes ▲ ▼` meta line and the work's notes as a paragraph; chapter notes become a spine — a rail with a rating dot and a multi-part chapter key per note (`341`, `5.07`) and the full note text, no cards, no clamp — with a Newest/Chapter sort (unnumbered notes last); the expanded character card is rebuilt on the same gutter. Two derived fields on the `WorkChapterRow` projection, no schema change. Supersedes polish-and-export Reqs 5.4 and 9.4 (and 5.1's order, in part) and design-doc §7's "no chapter-number parsing from titles" (Decision 2). | --- @@ -388,3 +389,21 @@ v2 plan item 4 (T-2229). A background sweep (plus a manual per-work trigger) run - [prerequisites.md](character-extraction/prerequisites.md) - [implementation.md](character-extraction/implementation.md) - [prototype/prototype-findings.md](character-extraction/prototype/prototype-findings.md)++## Work Detail Reading Redesign++**Created:** 2026-08-22 · **Status:** Done — all ten tasks across four phases (Core projection → Model → View → Docs and verification) implemented 2026-08-23; CHANGELOG carries one entry per phase.++Redesign of the work detail screen settled on a design canvas: the header loses its card and its three pulse cards (a meta line keeps the `work-detail-pulse*` identifiers the UI tests anchor on); chapter notes render as a spine — rail, rating dot, mono chapter key, row title and date, full note text — sorted Newest (default, per visit) or by Chapter with keyless notes last; the expanded character card shows aliases as chips, the note as a paragraph, and facts with their cited chapter key in the gutter as the tap target.++**Decisions worth knowing:**++- **Chapter keys are multi-part numbers** (Q15): `5-07` in a URL and `5.07` in a title are the same key `[5, 7]`, so volume.chapter works (The Wandering Inn) and decimal interludes sort into place.+- **Titles key only behind a chapter prefix** (Decision 2): `Chapter 14` keys 14; `Book 2, Chapter 14` and `Top 10 Moments` do not. Replaces design-doc §7's sentence.+- **The sort control is a capsule that wraps below the header** at accessibility sizes (Decision 1), not Stats' menu — two short labels, not five long ones.+- **No glow on rating dots or the selected pill** (Q11) and **sans row titles** (Q9): the style guide wins over the canvas where they disagreed.++- [smolspec.md](work-detail-reading-redesign/smolspec.md)+- [decision_log.md](work-detail-reading-redesign/decision_log.md)+- [tasks.md](work-detail-reading-redesign/tasks.md)+- [implementation.md](work-detail-reading-redesign/implementation.md)
diff --git a/specs/polish-and-export/requirements.md b/specs/polish-and-export/requirements.mdindex 0ce6532..a32e68e 100644--- a/specs/polish-and-export/requirements.md+++ b/specs/polish-and-export/requirements.md@@ -13,7 +13,7 @@ M5 completes the v1 feature set defined in `docs/asterism-design.md`: markdown e - No motion additions: no star twinkle, no parallax, no custom sheet transitions (style guide §9). - No thumbnail or cover-art fetching (v2); the work-detail cover is the glyph treatment only. - No new teach-mode capabilities beyond one: re-teaching an articles site, offered from the Sites screen only. Everything else routes into the existing teaching flow unchanged.-- No chapter-number parsing or ordering changes (§7 stands: lastSharedAt ordering in the app).+- No chapter-number parsing or ordering changes (§7 stands: lastSharedAt ordering in the app). **Superseded** (2026-08-23) by [`specs/work-detail-reading-redesign/`](../work-detail-reading-redesign/smolspec.md): chapter keys are now derived on read (Decision 2 there) and the work page offers chapter order beside newest-first. - No schema version bump and no backup-format change. - No performance budgets for export or search; neither is a plausible cost risk at this library's scale. @@ -76,10 +76,10 @@ M5 completes the v1 feature set defined in `docs/asterism-design.md`: markdown e **Acceptance Criteria:** -1. <a name="5.1"></a>The work detail screen SHALL present, top to bottom: header (glyph/cover, editable title, site + URL identity, type and genre tags), rating pulse, an "Open last noted chapter" action, generic notes, then the chapter-notes list.+1. <a name="5.1"></a>The work detail screen SHALL present, top to bottom: header (glyph/cover, editable title, site + URL identity, type and genre tags), rating pulse, an "Open last noted chapter" action, generic notes, then the chapter-notes list. **Superseded in part** (2026-08-23) — the *order* is restated by [`specs/work-detail-reading-redesign/`](../work-detail-reading-redesign/smolspec.md): the header carries no card, the pulse is a meta line inside it, the generic notes rise to sit directly under the tags as the page's lede, and Characters comes between the action and the chapter notes. Every element named above is still on the screen, and Req 5.2's counts are unchanged. 2. <a name="5.2"></a>The rating pulse SHALL show three counts: total entries, ▲-rated entries, ▼-rated entries — counts only, with no drill-down. 3. <a name="5.3"></a>WHEN the reader taps "Open last noted chapter", the system SHALL open the rawURL of the entry with the newest lastSharedAt; WHERE the work has no entries, the action SHALL NOT be shown.-4. <a name="5.4"></a>Chapter-notes rows SHALL show rating glyph, chapter title (falling back to cleaned capture title), note preview, and date, ordered newest-first by lastSharedAt.+4. <a name="5.4"></a>Chapter-notes rows SHALL show rating glyph, chapter title (falling back to cleaned capture title), note preview, and date, ordered newest-first by lastSharedAt. **Superseded** (2026-08-23) — replaced by [`specs/work-detail-reading-redesign/`](../work-detail-reading-redesign/smolspec.md): a row is a spine row on a rail — a rating *dot* over the derived chapter number in the gutter, the trimmed row title with its date, and the **whole** note with no line limit — never a two-line preview. Newest-first by lastSharedAt is still the order on every open, but it is now one of two: a `Newest | Chapter` control offers chapter-key order, ties oldest-first, unnumbered notes last. The chapter title's fallback chain is unchanged, and so is Req 5.1's rule that tapping a row opens that entry. 5. <a name="5.5"></a>~~The screen's toolbar SHALL provide a menu containing exactly: Export, Merge into…, Edit details, and Delete work. The existing Merge flow SHALL be reachable only from that menu.~~ **Superseded by Decision 5** (user feedback after device install): the screen splits into a read-only view mode and an explicit edit mode. View mode's toolbar carries Export and the affordance that enters the editor; the editor holds the metadata fields, the Work URL and URL-identity machinery, and Merge into… and Delete work. Merge is reachable only from edit mode. 6. <a name="5.6"></a>Title edits SHALL record manual provenance and survive re-parses; type and genre tags SHALL remain editable (in edit mode, per Decision 5 — they were editable in place when this was written). Saving SHALL write only where a draft differs from the loaded snapshot. @@ -130,7 +130,7 @@ M5 completes the v1 feature set defined in `docs/asterism-design.md`: markdown e 1. <a name="9.1"></a>Each screen with a primary action SHALL style exactly one control as the gradient primary button (e.g. Save/Update, "Looks right — apply", "Open last noted chapter"); all other buttons SHALL use the secondary or tertiary treatment. 2. <a name="9.2"></a>Site glyphs SHALL render as circles with the radial star-gradient treatment, colored deterministically from the hostname (no stored color field); unknown-site `?` and article ✎ glyphs SHALL be flat, dim, and unglowed. Rows that show a hostname as plain text SHALL show the glyph instead. 3. <a name="9.3"></a>The tab bar SHALL be the floating detached capsule with two items (◷ Recent, ✦ Works), active item cyan (with icon glow in dark). **Superseded in part** (2026-08-16) — the item count and icon set are extended by [`specs/stats-page/`](../stats-page/requirements.md) Req 1.1, which adds a third tab (Stats, carrying `chart.bar`); the floating detached capsule treatment and the cyan active item apply unchanged to all three.-4. <a name="9.4"></a>Rating toggles, inbox banner, unparsed rows with Teach pill, the Resolve pill (matching the Teach pill recipe per the design doc §5.1), the duplicate-resolution sheet, teach chips, count pills, tags, the unattached-notes dashed group, the rating pulse cards, and the provenance disclosure SHALL match their style-guide §7 recipes.+4. <a name="9.4"></a>Rating toggles, inbox banner, unparsed rows with Teach pill, the Resolve pill (matching the Teach pill recipe per the design doc §5.1), the duplicate-resolution sheet, teach chips, count pills, tags, the unattached-notes dashed group, the rating pulse cards, and the provenance disclosure SHALL match their style-guide §7 recipes. **Superseded in part** (2026-08-23) — the *rating pulse cards* clause is retired by [`specs/work-detail-reading-redesign/`](../work-detail-reading-redesign/smolspec.md): the three cards are gone and their counts are the header's **meta line**, whose recipe §7 now carries in their place. Work detail also adds three recipes this criterion did not name — the spine row, the selected cast pill, and the two-segment sort capsule — and they are held to §7 on the same terms. Every other recipe listed above applies unchanged. 5. <a name="9.5"></a>Glow SHALL appear only on colored site glyphs, primary buttons, the active tab icon (dark), selected teach chips, and active rating toggle controls, at the §5/§7 values; rating glyphs in rows and lists never glow. 6. <a name="9.6"></a>Corner radii SHALL follow the concentric system of style guide §6.
diff --git a/specs/work-detail-reading-redesign/decision_log.md b/specs/work-detail-reading-redesign/decision_log.mdnew file mode 100644index 0000000..6250dac--- /dev/null+++ b/specs/work-detail-reading-redesign/decision_log.md@@ -0,0 +1,98 @@+# Decision Log: Work Detail Reading Redesign++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-22 | The chapter key comes from `chapterSequence` first | The URL rule is the taught, site-specific source; titles are the fallback governed by Decision 2 |+| Q2 | 2026-08-22 | The spine stays a `List`; each row draws its own rail segment | Works reach M4 scale; `List` recycling matters more than a single continuous overlay |+| Q3 | 2026-08-22 | The Newest/Chapter choice is per visit, not stored | User choice; Newest is the reading default and a stored preference was not wanted yet |+| Q4 | 2026-08-22 | In Chapter order, keys ascend with ties oldest-first; unnumbered notes follow, oldest-first | User choice that unnumbered notes sit at the bottom, to be judged in practice; inside an ascending list every run reads oldest → newest |+| Q5 | 2026-08-22 | A leading chapter prefix plus the key's number is stripped from the row title; a title that is only the number gives no row title | User choice; the gutter already shows the number, so "341 · Chapter 341 — …" and "15 · 15" would say it twice |+| Q6 | 2026-08-22 | Month dividers are not part of the spine | They marked reading pace, not position; the gutter number and per-row date already carry both |+| Q7 | 2026-08-22 | The inline card, not a sheet, is the expanded character view | User choice; keeps the cast in the page and reuses the spine gutter for citations |+| Q8 | 2026-08-22 | The Characters section keeps its header | The manual-pass row and the proposals indicator live in it; the canvas omitted it for brevity only |+| Q9 | 2026-08-22 | Chapter rows use the sans face at 13 pt semibold, not `serifRowTitle` | `docs/asterism-style-guide.md` §3 reserves serif for work and screen titles |+| Q10 | 2026-08-22 | A live citation with no chapter key shows an `arrow.up.right` glyph in the gutter | The gutter must still read as a link; a bare "Ch." would read as a number that failed to render |+| Q11 | 2026-08-22 | No glow on the rating dots or on the selected cast pill | `docs/asterism-style-guide.md` §5 puts glow on identity and primary action only; neither is one. The canvas showed glow on both |+| Q12 | 2026-08-22 | A live fact with a key shows no caption; one without a key keeps the citation-title/date caption | The key says where it came from; without a key the arrow alone would not |+| Q13 | 2026-08-22 | The Chapter order is stored on `WorkDetailModel`, assigned with `presentation` in `load()` | Testable without SwiftUI, no per-evaluation sort, one moment for both orders, and the repository keeps one order as every other projection does |+| Q14 | 2026-08-22 | The selected cast pill is a `ConstellationPillKind` case; alias chips reuse `.count` | `ConstellationRecipes.swift` exists so a recipe is written once; the editor's alias chips already use `.count` |+| Q15 | 2026-08-22 | A chapter key is a multi-part number (`[Int]` components, compared component-wise) with the label as written | The Wandering Inn numbers chapters volume.chapter (`1.01`, `5.07`, URL `5-07`); a single integer keyed every chapter of a volume to the same number. Decimals such as `12.5` also sort into place instead of tying with 12 |+| Q16 | 2026-08-23 | `WorkChapterRow.init` derives `rowTitle` from `displayTitle` and `chapterKey`; it is not a parameter | A Swift default cannot name another parameter, and a `nil` default (the first cut) let a caller omit it and silently get a titleless row. Deriving in the initialiser keeps the key and the title it is trimmed against together, and rows built by hand (fixtures, previews) carry the same row title a read gives them. Pre-push review |+| Q17 | 2026-08-22 | A chapter prefix is a whole word against a following letter only; a digit may follow it directly | `Chapter14` keys 14 (the whitespace is optional per Decision 2), `Chapters 14` and `Party 3` key nothing. Sequence and title keys compare by components with the label as tie-break, so `007` and `7` are distinct keys and the Chapter order's `lastSharedAt` tie-break applies to identical keys only |+| Q18 | 2026-08-23 | The header's site row wraps: the meta line drops to its own line under the site identity when the row does not fit, and the meta line itself is a `FlowLayout` | At accessibility text sizes the one-row layout squeezed the hostname to a single clipped letter; §10 forbids clipping. Same fallback shape as Decision 1 |+| Q19 | 2026-08-23 | The fact gutter is 44 pt (`AsterismLayout.minHitTarget`) on every fact row | The smolspec asked for a 34 pt gutter minimum and a 44 × 44 pt citation button; a 34 pt dash row beside a 44 pt button row staggered the statements in one card by 10 pt, which a gutter exists to prevent. One width satisfies the 34 pt minimum and the hit target. Pre-push review |+| Q20 | 2026-08-23 | The generic/dangling fact gutter shows an em dash as `Text` in `.subheadline`, not a drawn 10 pt rule | It shares the statement's metrics, so it sits on the first line at every text size; a drawn rule drifts as the type scales |+| Q21 | 2026-08-23 | The sort capsule's segments never wrap; where the two cannot share a line (accessibility XXXL) the control stacks them in a rounded rectangle, each segment full width, with the segment height scaling with the 32 pt visual | Decision 1's claim that the capsule fits alone on a line failed at XXXL — "Newest" hyphenated. Both segments stay visible and unhyphenated at every size; verified in the simulator at the default and XXXL sizes |+| 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 |++## Decision 1: Sort Control Is a Capsule That Wraps Below the Header++**Date**: 2026-08-22+**Status**: accepted++### Context++The chapter list gains a Newest/Chapter sort. `specs/stats-page/` Q31 chose a `Menu` over a segmented control for the Stats period because five labels of "This month" length cannot fit a segmented control at accessibility Dynamic Type sizes, and Req 7.8 there forbids clipping. The canvas for this redesign shows a two-segment capsule beside the "Chapter notes" section header, and the same Dynamic Type constraint applies to it.++### Decision++Use a two-segment capsule (`Newest | Chapter`) beside the section header, inside a `ViewThatFits` that moves the capsule to its own line under the header when the two do not fit side by side.++### Rationale++Two short labels differ from Stats' five long ones: the capsule fits beside the header at every standard size, and at accessibility sizes it fits alone on a line. Keeping both states visible at once is what makes the control read as "which order am I in" without opening anything, which a menu cannot do.++### Alternatives Considered++- **`Menu`, as Stats Q31**: one 44 pt target at every size - Rejected because the current order is then a label to read rather than a state to see, and the constraint that forced Stats to a menu (five long labels) is absent here.+- **Capsule with no fallback**: simplest - Rejected because at the largest accessibility sizes the header plus two segments would clip or truncate, which `docs/asterism-style-guide.md` §10 forbids.++### Consequences++**Positive:**+- The order is visible at a glance and switches in one tap.+- Fits at every Dynamic Type size without a different control per size.++**Negative:**+- Two control shapes for "choose among a few" now exist in the app (Stats' menu, this capsule); the style guide §7 entry must name when each applies.+- The section header becomes an `HStack` for this screen only.++---++## Decision 2: Titles Contribute a Chapter Key Only Behind a Chapter Prefix++**Date**: 2026-08-22+**Status**: accepted++### Context++Chapter order needs a number per note. The URL-rule `chapterSequence` is the taught source, but a site with a title rule and no URL rule leaves every note unnumbered. `docs/asterism-design.md` §7 states "No chapter-number parsing from titles — unreliable (interludes, 'Chapter 12.5', rewrites)". Taking the first digit run anywhere in a title mis-keys "Book 2, Chapter 14" as 2 and "Top 10 Moments" as 10, and each wrong key reorders the spine.++### Decision++Key from `chapterSequence` first. Otherwise key from the title only when it starts with a chapter prefix (`chapter`, `ch.`, `ch`, `episode`, `ep.`, `ep`, `part`, whole word, case-insensitive) followed by a number group. A key is the number group's digit runs as integer components compared in order (Q15), so `5.07` and `12.5` are distinct, ordered keys. Any other title yields no key. The design-doc sentence is replaced by this rule.++### Rationale++A title that opens with "Chapter 14" is the site telling the reader its number; that is the case the user wants numbered and the one §7's objection does not cover. The cases §7 names — interludes, rewrites, decimals — either yield no key (no prefix) or, for decimals and volume.chapter numbering, sort component-wise into their real place. The user has chosen to judge the rule in practice.++### Alternatives Considered++- **Sequence only**: keeps §7 intact - Rejected because titled-but-unruled chapters, a common capture, would all sit unnumbered at the bottom in Chapter order, making the order useless on those sites.+- **First digit run anywhere in the title**: simplest - Rejected for the mis-keys above; the gutter and the Chapter sort are unguarded, unlike title trimming.++### Consequences++**Positive:**+- Works with a title rule and no URL rule get a usable Chapter order.+- No new parsing state is persisted; the key is derived on read and can change with the rule.++**Negative:**+- A title that starts "Part 2" for the second half of one chapter keys 2; the Newest order is unaffected.+- `docs/asterism-design.md` §7 needs its sentence replaced in this change.++---
diff --git a/specs/work-detail-reading-redesign/implementation.md b/specs/work-detail-reading-redesign/implementation.mdnew file mode 100644index 0000000..11419b0--- /dev/null+++ b/specs/work-detail-reading-redesign/implementation.md@@ -0,0 +1,205 @@+# Implementation: Work Detail Reading Redesign++Written at the pre-push review of `feature/work-detail-reading-redesign`+(2026-08-23). Explains the branch at three levels, then assesses+completeness against `smolspec.md`.++## Beginner Level++### What Changed++The work page — the screen you see when you tap a story in the Works tab —+used to show a stack of boxes (the title box, three little count boxes, a+notes box) and then each chapter note as its own card with the note text cut+off after two lines. You saw a lot of chapter titles and not much of what you+had written.++Now the page shows your notes. The top is just the title, one line with the+site's glyph and address, a small `2 notes ▲ 1 ▼ 0` line, the tags, and your+notes about the work as a paragraph. Below that the chapter notes run down a+"spine": a thin vertical line with a dot for each note (blue-green if you+rated it up, purple if down, hollow if unrated), the chapter number under the+dot, and the whole note beside it — nothing cut off. A `Newest | Chapter`+switch lets you read newest-first (the default every time you open the page)+or in chapter order.++Tapping a character pill still opens its card, which now shows the+character's other names as chips, the note about them, and each fact with the+chapter number it came from on the left — tap the number to open that note.++### Why It Matters++A reading journal is for re-reading what you wrote. The old page made you tap+into each note to read it; the new one lets you scroll the notes as one+continuous read and jump to any chapter.++### Key Concepts++- **Chapter key**: the number the app works out for a note — from the web+ address if a site rule was taught (`5-07` → `5.07`), otherwise from a title+ 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.+- **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+ (or, on a character card, the chapter link).++---++## Intermediate Level++### Changes Overview++| 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 | `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:)`. |+| View | `WorkDetailView.swift` | Folded header + meta line; `WorkChapterSpineRow` / `WorkChapterRowView` (private); `sortControl` capsule in a `ViewThatFits`; `characterDetailCard` on the gutter layout. |+| Tests | package, unit, UI | `ChapterKeyDerivationTests`, `WorkDetailReadTests` (+2), `WorkDetailModelTests` (+4), `ConstellationPillKindTests`, a sort UI test, an alias-chip assertion. |+| Docs | style guide, design doc, polish-and-export reqs, CHANGELOG, OVERVIEW | Describe the shipped layout; Decision 2 replaces design-doc §7's "no chapter-number parsing" sentence. |++### Implementation Approach++- **Derive, never persist.** Chapter keys are a projection computed inside+ the repository's locked read from columns it already has+ (`chapterSequence`, `chapterTitle`). Re-teaching a site renumbers the spine+ with no migration. The same per-row key feeds the character facts, so a+ citation's gutter and its note's gutter can never disagree.+- **Guarded title fallback (Decision 2).** `ChapterKeyDerivation` scans the+ sequence for the first *number group* (digit runs joined by single `.`/`-`)+ and falls back to the title only behind a whole-word chapter prefix.+ Non-ASCII digits and `Int` overflow yield no key.+- **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.+- **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+ area. Segments overdraw 0.5 pt at each end; the first/last rows cap at the+ dot centre. The `List` is kept (Q2) for recycling at M4 scale.+- **Wrap, never clip (§10).** Three `ViewThatFits` fallbacks: the meta line+ drops under the site row (Q18), the sort capsule drops under the section+ header (Decision 1), and the capsule's segments stack when they can't share+ a line (Q21).+- **Recipes over one-offs (Q14).** Selected pill is a `ConstellationPillKind`+ case; alias chips reuse `.count`; the sort surface is one `sortSurface(_:)`+ helper on either shape.++### Trade-offs++- Title keys can be wrong for "Part 2" of a split chapter — accepted; it only+ affects Chapter order and the gutter, never Newest (Decision 2).+- `rowTitle` is derived in `WorkChapterRow.init` rather than passed in.+ Slightly more work per row construction; in exchange the key and the trimmed+ title can't come apart, and hand-built rows match read rows (Q16).+- The fact gutter is 44 pt on every row, not the 34 pt the smolspec sketched+ for dash rows, so statements in a card align (Q19).+- A second array of rows (`chapterRowsByChapter`) costs ~112 B × rows; the+ strings are shared. Immaterial at M4 scale.++---++## Expert Level++### Technical Deep Dive++**`ChapterKey` ordering.** `<` compares components pairwise, then by count+(shorter prefix first: `12 < 12.5 < 13`), then by `label` — the label+tie-break makes `<` a strict total order consistent with the synthesised+`==`, so `007` and `7` are distinct, ordered keys. Consequence for Q4's+"ties by `lastSharedAt`": the date tie-break only fires for identical keys;+`007` vs `7` resolves on the label. Documented in Q17.++**Scanning.** The derivation materialises `[Character]` per title and scans+by index. For a row with a non-nil `chapterTitle`, `key()` and `rowTitle()`+each scan the same string once (the key from `chapterTitle`, the row title+from `displayTitle`), so a prefixed title is prefix-matched twice. At ~500+rows that is ~1–2 ms inside a read that costs tens of ms in SwiftData; left+as is because merging the two scans would require `init` to know+`chapterSequence`, which cuts against Q16.++**Prefix rule edge cases.** A prefix ending in a letter must be followed by+a non-letter: `Chapters 14` and `Party 3` key nothing, `Chapter14` keys 14+(Q17). Separators continue a group only when a digit follows, so `Chapter+12. The Long Way` parses `12` and then strips `.` as a title separator+(because whitespace follows it); `Chapter 12.The` keeps `.The`. An empty+remainder keeps `displayTitle` whole, so "Chapter 14" alone shows "Chapter+14" beside gutter "14" — that is Q5's rule, not a defect.++**Rail geometry.** `rail` re-renders the gutter hidden and stretched to the+row height purely as a width sizer, then overlays a `GeometryReader` that+draws the line from `isFirst ? dotCentreOffset : -0.5` to+`isLast ? dotCentreOffset : height + 0.5`. A single-row list draws a+zero-height line. The sizer is needed because the gutter grows with its key+label at accessibility sizes; a `PreferenceKey` would avoid the duplicate+subtree at the cost of a second layout pass — not worth it at ~12 visible+rows.++**Accessibility contract.** Every `work-detail-*` identifier the six UI+suites anchor on survives: the meta line keeps `work-detail-pulse` (container,+`.contain`) and the three value identifiers with explicit labels; spine rows+keep `work-detail-entry` with label "Note on {displayTitle}[, rated up/down]"+and the note as `accessibilityValue`; the three child identifiers the old row+had are retired (no test referenced them). Sort segments carry `.isSelected`.++### Architecture Impact++- `AsterismCore` gains one public type (`ChapterKey`) on two public+ projections; `ChapterKeyDerivation` stays internal. No schema, no+ migration, no share-extension impact.+- `WorkChapterRow.init` now calls into the derivation; any future field that+ depends on the key should follow the same pattern rather than add a+ defaulted parameter.+- `characterPresentations` / `factRows` now thread a fourth per-entry side+ table (`keys`). A fifth would justify collapsing them into one+ `[UUID: CitedNote]` — flagged in review, deferred as out of scope.+- Two "choose among a few" controls now coexist (Stats' `Menu`, this+ capsule); the style guide §7 names when each applies.++### Potential Issues++- Title-derived mis-keys on sites whose titles start "Part N" for halves of+ one chapter — user has opted to judge in practice (Decision 2).+- `Calendar.current.isDateInToday` is evaluated per row per body; a page+ left open across midnight shows "Today" until the next body evaluation.+ Cosmetic.+- The stacked-segment fallback uses `RoundedRectangle(cardRadius)` rather+ than a capsule; a future kit "segmented" recipe should absorb both shapes.+- UI tests anchor on frames (`frame.minY`) for the sort test; a future+ layout with sticky section headers could change which element is "above".++---++## Completeness Assessment++**Fully implemented** — every MUST in `smolspec.md`:+header (title, site row, meta line with identifiers and labels, pills, notes+paragraph, gradient button placement); chapter notes (gutter dot/key,+title+date line, full note, rail with end caps, no cards/month headers,+`work-detail-entry` label/value, sort capsule with identifiers and+`.isSelected`, Newest on every open, Chapter order per Q4, key derivation per+Decision 2/Q15, `rowTitle` per Q5); characters (`selectedTypeTag`, alias+chips, note paragraph, hairline, facts on the gutter with citation+button/glyph/caption rules per Q10/Q12, dangling identifier); general+(`make test-core` clean, unit and the six UI suites pass, the three+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).++**Partially implemented / missing**: none found. The manual rail-join check+the Risks section asks for was performed in the simulator at the default and+accessibility-XXXL sizes (screenshots under `/tmp/asterism-rail-join/` at+review time; not committed).++**Open for the author**: whether "Chapter 14" alone should show a row title+at all (Q5's empty-remainder rule keeps it, so the number appears twice —+gutter and title). Spec-conformant; worth a look on the device.
diff --git a/specs/work-detail-reading-redesign/smolspec.md b/specs/work-detail-reading-redesign/smolspec.mdnew file mode 100644index 0000000..41e8e46--- /dev/null+++ b/specs/work-detail-reading-redesign/smolspec.md@@ -0,0 +1,58 @@+# Work Detail Reading Redesign++## Overview++The work detail screen (`Asterism/Asterism/Views/WorkDetailView.swift`, view mode) stacks four glass surfaces above the first chapter note and then shows each note as a card with its text clamped to two lines, so a reader sees titles rather than notes. This change makes the notes the content: the header folds into a title, a site line and the work's own notes as a paragraph; the chapter notes become a "spine" — a rail with a rating dot and chapter number per note and the full note text beside it — with a Newest/Chapter sort; and the expanded character card is rebuilt on the same gutter. The design is settled on the canvas at https://claude.ai/code/artifact/b2fba350-6fd9-4700-877b-aed19568fff9 (page *Spine*: `Newest first`, `By chapter`; page *Characters*: `Inline card`); this document restates everything the implementation needs, so the canvas is reference only. Edit mode is unchanged.++Type sizes below are Dynamic Type text styles, never fixed points: "15 pt" means `.subheadline`, "13 pt semibold" means `.footnote.weight(.semibold)`, dates and captions are `.caption2` / `.caption`. Dot size and radii are fixed; gutters have a minimum width and grow with their text.++## Requirements++Header (view mode), top to bottom+- The system MUST show the header without a card: the full wrapping title (`AsterismTypography.serifHeading`, identifier `work-detail-title`); then one row of `SiteGlyph` (40 pt), a two-line text block (hostname in `.caption` `secondaryText`, identifier `work-detail-hostname`; the URL identity in `AsterismTypography.mono`, identifier `work-detail-url-identity`; each one line, tail-truncated, as today), the meta line, and the existing link glyph (`work-detail-link`); then the type and genre pills (`work-detail-type-tag`, group `work-detail-tags`). Every header identifier listed here keeps its current name.+- The meta line MUST read `{notes} note(s) ▲ {up} ▼ {down}` in `.caption`, pluralised with `Pluralisation.count`: the notes count `primaryText` semibold, ▲ and its count cyan, ▼ and its count violet. It replaces the three pulse cards and MUST keep the identifiers `work-detail-pulse` (container, `accessibilityElement(children: .contain)` before the identifier) and `work-detail-pulse-notes`, `work-detail-pulse-up`, `work-detail-pulse-down` on the three values, each combined into one element with an explicit `accessibilityLabel` of `"{n} note(s)"`, `"{n} rated up"`, `"{n} rated down"`.+- The work-level notes MUST render as a plain paragraph (15 pt, `noteText`, no card, no section header) under the pills, identifier `work-detail-notes`, absent when empty.+- "Open last noted chapter" MUST stay the screen's one gradient button, placed after the work-level notes (directly after the pills when there are none) and before the Characters section.++Chapter notes+- Each note MUST render as a row with a gutter (minimum 44 pt, growing to fit its key) and a content column. Gutter: a 10 pt dot — cyan fill for ▲, violet fill for ▼, `secondaryText` 1.5 pt stroke on a clear fill when unrated; no glow (Q11) — with the chapter key's `label` in `AsterismTypography.mono` `secondaryText` beneath it, nothing beneath it when there is no key. Content: a title line — the row title (13 pt semibold `primaryText`, one line, tail-truncated) with the date trailing (`.caption2` `secondaryText`; "Today" when `lastSharedAt` is today, else the abbreviated date); when `rowTitle` is nil the line holds the date alone — then the full note text (15 pt, `noteText`, `lineSpacing` 4) with no line limit, absent when empty.+- A 1 pt vertical rail in `AsterismColors.cardBorder` MUST run through the dots from the first dot to the last. Rows use a zero vertical `constellationListRow` inset and carry their spacing as internal padding, so each row's segment meets its neighbours'; the first row's segment starts at its dot and the last row's ends at its dot. The rail is drawn by the row container's `.background`, outside the row's `Button`, with `allowsHitTesting(false)`, so a press does not dim it and it does not widen the tap area. The list stays a `List` (Q2).+- Notes MUST NOT be wrapped in per-note cards and MUST NOT be grouped under month headers.+- Each row MUST keep identifier `work-detail-entry` and MUST open its entry through `onSelectEntry` as today (polish-and-export Q56); with a nil `onSelectEntry` the row renders without a button. Its `accessibilityLabel` is "Note on {displayTitle}" (untrimmed) followed by ", rated up" / ", rated down" where rated, and its `accessibilityValue` is the note text. The child identifiers `work-detail-entry-rating`, `work-detail-entry-title`, `work-detail-entry-note` are retired (no test references them).+- The "Chapter notes" section header MUST become an `HStack` of the existing `ConstellationSectionHeader` (unchanged, still a single `Text`) and, trailing, a two-segment capsule `Newest | Chapter` (Decision 1: capsule `cardFill` fill with a 1 pt `cardBorder`; selected segment cyan .14 fill, cyan .4 border, cyan text; unselected `secondaryText`; `.caption` semibold; segments 32 pt tall inside a 44 pt hit target; wrapped in `ViewThatFits` so that when the header and capsule do not fit on one line the capsule moves to its own line beneath the header, leading-aligned) whose two `.plain` `Button`s carry identifiers `work-detail-sort-newest` / `work-detail-sort-chapter` and the `.isSelected` trait on the current one (precedent: `EntryDetailView.swift:506`, `StatsView.swift:683`). Newest MUST be selected on every open of the screen (Q3).+- Newest MUST be the repository's order (`lastSharedAt` descending, unchanged). Chapter MUST be: rows with a key sorted by key ascending, ties by `lastSharedAt` ascending; then rows without a key by `lastSharedAt` ascending (Q4). Both orders are stored on `WorkDetailModel` and assigned in the same statement as `presentation` in `load()` (Q13), so they always describe the same moment and sorting never runs per body evaluation.+- The chapter key MUST be derived in the repository on `WorkChapterRow` as a `ChapterKey` (Decision 2, Q15): a *number group* is one or more runs of ASCII digits `0–9` joined by single `.` or `-` separators (`341`, `5.07`, `5-07`, `1.01`, `12.5`); the key's `components: [Int]` are the runs parsed as integers and its `label: String` is the runs joined by `.` as written (`5-07` → components `[5, 7]`, label `5.07`; `007` → `[7]`, label `007`). The key is the first number group in `EntrySnapshot.chapterSequence`; else, only when `chapterTitle` starts (after leading whitespace, case-insensitive) with one of the chapter prefixes `chapter`, `ch.`, `ch`, `episode`, `ep.`, `ep`, `part` as a whole word followed by optional whitespace and a number group, that number group; else nil. A run that does not fit `Int` yields nil. Keys compare component-wise, shorter-prefix first (`12` < `12.5` < `13`; `5.07` < `5.10` < `6.01`). "Book 2, Chapter 14", "Top 10 Moments", "2nd Interlude" and "Interlude – Pisces" yield nil.+- `WorkChapterRow.rowTitle: String?` MUST be derived in the repository (Q5). Start from `displayTitle`. If it is a single number group whose components equal the key's (a sequence-only chapter such as "15", or The Wandering Inn's "5.07"), `rowTitle` is nil. Otherwise, if it starts (after leading whitespace, case-insensitive) with one of `chapter`, `ch.`, `ch`, `episode`, `ep.`, `ep`, `part` as a whole word, then optional whitespace, then a number group whose components equal the key's (`Chapter 007` trims against `[7]`; `Chapter 3.1` trims against `[3, 1]`; `Chapters 14` does not match `ch`), the prefix, the number group and one optional separator (`:`, `-`, `–`, `—`, `.`) that is followed by whitespace or end of string are removed and the remainder whitespace-trimmed; an empty remainder leaves `rowTitle` as `displayTitle` unchanged. `rowTitle` is used only by the spine row; `displayTitle` still names entry detail and the accessibility label.++Characters (view mode)+- The selected pill MUST be distinguishable: a new `ConstellationPillKind.selectedTypeTag` (violet .26 fill, violet .6 border, no glow — Q11) and the chevron pointing up; other pills unchanged. Pills keep identifier `work-detail-character` and their current frame.+- The expanded card MUST show, in order: the serif name with the aliases as `constellationPill(.count)` chips (identifier `work-detail-character-alias-chip` each, the same recipe the editor's alias chips use) on one wrapping line, plus the existing torn label where the character is torn; the character note as a 15 pt `noteText` paragraph, absent when empty; a 1 pt `cardBorder` hairline; then the facts in the repository's order (character-extraction Q88).+- Each fact MUST render as a row with a gutter (minimum 34 pt) and the statement (15 pt `noteText`, a static text, identifier `work-detail-character-fact` on the row container as today) beside it. A fact whose cited entry is live shows in the gutter a `.plain` `Button` (frame at least 44 × 44 pt, identifier `work-detail-character-citation`, label "Open {citationTitle, else Noted {date}}") that opens the entry through `onSelectEntry`, rendered as the entry's chapter key `label` in `AsterismTypography.mono` cyan semibold, or `arrow.up.right` in cyan when that entry has no key (Q10); with a nil `onSelectEntry` the gutter shows the same glyph without a button. A live fact with a key shows no caption; a live fact without a key keeps today's caption (`citationTitle`, else "Noted {date}") under the statement so the reader can tell which note is cited (Q12). A fact from the work's notes or from a note that is gone shows a 10 pt `secondaryText` dash in the gutter and keeps today's caption ("From this work's notes" / "From a note that is no longer here", identifier `work-detail-character-citation-dangling` for the latter).+- The Characters section MUST keep its `ConstellationSectionHeader`, the manual-pass row, and the proposals indicator unchanged.++General+- Light mode follows from the existing `AsterismColors` pairs; no new colour tokens. Reduce Transparency is satisfied by the existing recipes.+- `make test-core` MUST pass with no new warnings; the unit-test bundle and the UI suites named below MUST pass.+- The documents this change supersedes MUST be updated in the same change: `docs/asterism-style-guide.md` §7 (rating pulse → meta line; selected cast pill recipe) and §3 (chapter rows in sans), `docs/asterism-design.md` §6–§7 (work detail layout; the "no chapter-number parsing from titles" sentence replaced by the guarded rule of Decision 2), and `specs/polish-and-export/requirements.md` Reqs 5.4 and 9.4 marked superseded by this spec.++## Implementation Approach++- `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift`: add `chapterKey: ChapterKey?` (a `Comparable`, `Sendable` struct of `components: [Int]` and `label: String`, in the new helper file) and `rowTitle: String?` to `WorkChapterRow`, defaulted in `init` (`nil`; `rowTitle` defaulting to `displayTitle`) so `Asterism/AsterismTests/Helpers/TestFixtures.swift:111` compiles; derive both where rows are built (line ~107) through a `nonisolated` helper `ChapterKeyDerivation` (new file beside it) unit-tested in `Packages/AsterismCore/Tests/AsterismCoreTests/`. Extend the `zip(entries, rows)` that builds `titlesByEntry` to also build `[UUID: ChapterKey]` keys, passed as a defaulted parameter through `characterPresentations` / `factRows` into a new `citedChapterKey: ChapterKey?` on `WorkCharacterFactRow` (`Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift`), defaulted in its `init` so `Asterism/AsterismTests/WorkDetailCharacterTests.swift:36` and `CharacterReviewModelTests.swift:46` compile.+- `Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift`: add `ConstellationPillKind.selectedTypeTag`.+- `Asterism/Asterism/ViewModels/WorkDetailModel.swift`: `enum ChapterSortOrder { case newest, chapter }`, a stored `chapterRowsByChapter` assigned alongside `presentation` in `load()`, and `chapterRows(for:)`; the view holds `@State private var sortOrder: ChapterSortOrder = .newest`.+- `Asterism/Asterism/Views/WorkDetailView.swift`: replace `viewHeaderSection`, `pulseSection` and `viewNotesSection` with the folded header; keep `openLastNotedSection`; rewrite `WorkChapterRowView` as the spine row (`HStack` of gutter `VStack` and content `VStack`, `isFirst` / `isLast` inputs for the rail end caps, rail in the container background); the sort control in the section header; restyle `characterPill`; rebuild `characterDetailCard` / `characterFactRow` on the gutter layout.+- Patterns: `constellationListRow()` rows on `List` with `.scrollContentBackground(.hidden)`; `constellationPill` for chips and pills; identifiers on leaf elements, `accessibilityElement(children: .contain)` on groups (`docs/agent-notes/testing.md`); `FlowLayout` (`TeachingComponents.swift`) for the pill grid; `Pluralisation.count` for counts.+- Tests: unit tests for `ChapterKeyDerivation` (sequence-first, guarded title fallback, no key for "Book 2, Chapter 14" / "Top 10 Moments" / "2nd Interlude" / "Interlude – Pisces", overflow, non-ASCII digits, the prefix table, `Chapters 14`, `Chapter 007`, `Chapter 3.1`, multi-part keys `5-07` / `5.07` / `1.01` / `12.5` and their ordering `12 < 12.5 < 13`, empty remainder, sequence-only and "5.07"-only titles → nil `rowTitle`) and for `chapterRowsByChapter` (ascending keys, ties, nil-key tail); `WorkDetailActionsUITests` gains a sort test on the `seeded-composed` scenario (`Asterism/Asterism/ViewModels/AppLibraryModel.swift:1465`, "Chapter 7 - Real Work" then "Chapter 8 - Real Work"): the two `work-detail-entry` elements whose labels contain "Chapter 7" / "Chapter 8" compare by `frame.minY` — 8 above 7 under Newest, 7 above 8 after tapping `work-detail-sort-chapter`; `CharacterExtractionUITests.swift:177` changes from asserting no `staticTexts["Also Nightjar"]` to asserting no `work-detail-character-alias-chip` labelled "Nightjar"; the statement `staticTexts` assertions at lines 172, 175 and 331 keep matching because the statement stays a static text; the existing anchors in `WorkDetailActionsUITests`, `AccessibilityJourneyUITests`, `CharacterExtractionUITests`, `DuplicateResolutionUITests`, `ComposedSurfaceUITests`, `M4ScaleRecentPerformanceUITests` are kept by the identifiers above.+- Out of scope: edit mode (header fields, character editor), the Works tab list, entry detail, markdown export, any stored sort preference, the month dividers and the sheet variant shown on the canvas.++## Risks and Assumptions++- 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: 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`.++## 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.
diff --git a/specs/work-detail-reading-redesign/tasks.md b/specs/work-detail-reading-redesign/tasks.mdnew file mode 100644index 0000000..e0d801f--- /dev/null+++ b/specs/work-detail-reading-redesign/tasks.md@@ -0,0 +1,52 @@+---+references:+ - specs/work-detail-reading-redesign/smolspec.md+ - specs/work-detail-reading-redesign/decision_log.md+---+# Work Detail Reading Redesign++## Core projection++- [x] 1. Chapter rows carry a derived chapter key and row title <!-- id:m6b3h44 -->+ - Per smolspec.md (Chapter notes, Decision 2, Q5, Q15): `WorkChapterRow` gains `chapterKey: ChapterKey?` (Comparable struct of `[Int]` components and a `label`) and `rowTitle: String?`, both defaulted in `init` so `Asterism/AsterismTests/Helpers/TestFixtures.swift:111` still compiles, and both derived in `LibraryRepository+WorkDetail.swift` through a `nonisolated` helper in a new file. Verify: `Packages/AsterismCore/Tests/AsterismCoreTests/` covers sequence-first, the guarded title prefixes, no key for "Book 2, Chapter 14" / "Top 10 Moments" / "2nd Interlude" / "Interlude – Pisces", overflow and non-ASCII digits, multi-part keys `5-07` / `5.07` / `1.01` / `12.5` with ordering `12 < 12.5 < 13`, trimming of `Chapter 007` / `Chapter 3.1`, no trim for `Chapters 14`, empty remainder, and nil `rowTitle` for "15" and "5.07"; `make test-core` passes.++- [x] 2. Character facts carry the cited entry's chapter key <!-- id:m6b3h45 -->+ - Per smolspec.md (Characters, Implementation Approach): `WorkCharacterFactRow` gains `citedChapterKey: ChapterKey?`, defaulted in `init` so `WorkDetailCharacterTests.swift:36` and `CharacterReviewModelTests.swift:46` compile, populated from the same per-entry keys the `titles` dictionary is built from in `workDetail(id:)` and nil for generic-notes and dangling facts. Verify: a `WorkDetailReadTests` case shows a live citation's key present and a dangling citation's key nil; `make test-core` passes.+ - Blocked-by: m6b3h44 (Chapter rows carry a derived chapter key and row title)++## Model++- [x] 3. Work detail model exposes the Newest and Chapter orders <!-- id:m6b3h46 -->+ - Per smolspec.md (Chapter notes, Q3, Q4, Q13): `WorkDetailModel` gains `ChapterSortOrder { newest, chapter }` and a stored Chapter order assigned in the same statement as `presentation` in `load()` — keys ascending, ties by `lastSharedAt` ascending, then keyless rows by `lastSharedAt` ascending. Verify: `Asterism/AsterismTests` cases for ascending keys including `5.07 < 5.10`, a tie, and the keyless tail; the Newest order is the repository's order untouched; `make test-quick` passes.+ - Blocked-by: m6b3h44 (Chapter rows carry a derived chapter key and row title)++## View++- [x] 4. Header folds into title, site row, meta line, pills and lede <!-- id:m6b3h47 -->+ - Per smolspec.md (Header): the header card, the three pulse cards and the Notes card are replaced by the wrapping title, the `SiteGlyph`/hostname/URL-identity row with the pluralised `{n} notes ▲ {up} ▼ {down}` meta line and link glyph, the pills, the work notes as a paragraph (absent when empty), then the gradient button. Verify: identifiers `work-detail-title`, `-hostname`, `-url-identity`, `-link`, `-type-tag`, `-tags`, `-notes`, `-pulse` (container, `.contain`) and `-pulse-notes/-up/-down` with labels `{n} notes` / `{n} rated up` / `{n} rated down` are present; `WorkDetailActionsUITests` and `AccessibilityJourneyUITests` pass.++- [x] 5. Chapter notes render as the spine <!-- id:m6b3h48 -->+ - Per smolspec.md (Chapter notes, Q2, Q9, Q11): each row shows the gutter (rating dot without glow, key label in mono), the row title with trailing date ("Today" or abbreviated), and the full note with no line limit; rows use a zero vertical inset with internal spacing and draw their rail segment in the container background outside the button, first/last rows capped at their dots; accessibility label "Note on {displayTitle}[, rated up/down]" and value = note text; identifiers `work-detail-entry-rating/-title/-note` retired. Verify: the `work-detail-entry` anchors in `WorkDetailActionsUITests`, `AccessibilityJourneyUITests` and `CharacterExtractionUITests` pass; the rail join is inspected in the simulator at the default and the largest accessibility text size (manual check, recorded in the task notes).+ - Blocked-by: m6b3h46 (Work detail model exposes the Newest and Chapter orders), m6b3h47 (Header folds into title, site row, meta line, pills and lede)++- [x] 6. Newest/Chapter sort control reorders the spine <!-- id:m6b3h49 -->+ - Per smolspec.md (Chapter notes, Decision 1, Q3): the section header becomes an `HStack` of `ConstellationSectionHeader` and the two-segment capsule inside `ViewThatFits` (capsule drops below the header when it does not fit), buttons `work-detail-sort-newest` / `work-detail-sort-chapter` with `.isSelected` on the current one; Newest on every open. Verify: a new `WorkDetailActionsUITests` case on `seeded-composed` compares `frame.minY` of the entries labelled "Chapter 7" / "Chapter 8" — 8 above 7 under Newest, 7 above 8 after tapping Chapter; `make test-quick` passes.+ - Blocked-by: m6b3h48 (Chapter notes render as the spine)++- [x] 7. Selected character pill and alias chips use kit recipes <!-- id:m6b3h4a -->+ - Per smolspec.md (Characters, Q11, Q14): `ConstellationPillKind.selectedTypeTag` (violet .26 fill, .6 border, no glow) is added in `ConstellationRecipes.swift`; the expanded pill uses it with its chevron pointing up and keeps identifier `work-detail-character` and frame; aliases render as `constellationPill(.count)` chips with identifier `work-detail-character-alias-chip` beside the serif name. Verify: `ConstellationKitTests` cover the new kind's colours; `CharacterExtractionUITests.swift:177` asserts no alias chip labelled "Nightjar" instead of the old `staticTexts` check and the suite passes.+ - Blocked-by: m6b3h47 (Header folds into title, site row, meta line, pills and lede)++- [x] 8. Expanded character card lays out note and facts on the gutter <!-- id:m6b3h4b -->+ - Per smolspec.md (Characters, Q10, Q12): the card shows name + alias chips (+ torn label), the note paragraph (absent when empty), a hairline, then facts with a 34 pt gutter: a live citation is a 44 pt `.plain` button `work-detail-character-citation` labelled "Open {citationTitle, else Noted {date}}" showing the key label or `arrow.up.right`, caption kept only when there is no key; generic and dangling facts show a dash and keep their captions (`work-detail-character-citation-dangling`); statements stay static texts on a `work-detail-character-fact` row; nil `onSelectEntry` renders the glyph without a button. Verify: `CharacterExtractionUITests` (statement `staticTexts` at lines 172, 175, 331; citation tap) and `WorkDetailCharacterTests` pass.+ - Blocked-by: m6b3h45 (Character facts carry the cited entry's chapter key), m6b3h4a (Selected character pill and alias chips use kit recipes)++## Docs and verification++- [x] 9. Style guide, design doc and superseded requirements describe the shipped layout <!-- id:m6b3h4c -->+ - Per smolspec.md (General): `docs/asterism-style-guide.md` §3 (chapter rows in sans), §7 (rating pulse → meta line; selected cast pill recipe; when a capsule vs a menu applies, per Decision 1), `docs/asterism-design.md` §6–§7 (work detail layout; the title-parsing sentence replaced by Decision 2's rule), and `specs/polish-and-export/requirements.md` Reqs 5.4 and 9.4 marked superseded by this spec. Verify: no remaining mention of pulse cards or note previews for work detail in those documents.+ - Blocked-by: m6b3h49 (Newest/Chapter sort control reorders the spine), m6b3h4b (Expanded character card lays out note and facts on the gutter)++- [x] 10. Full suites pass with no new warnings <!-- id:m6b3h4d -->+ - Verify: `make test-core`, `make test-quick`, and the UI suites `WorkDetailActionsUITests`, `AccessibilityJourneyUITests`, `CharacterExtractionUITests`, `DuplicateResolutionUITests`, `ComposedSurfaceUITests`, `M4ScaleRecentPerformanceUITests` pass; a forced recompile of every changed Swift file (touch, rebuild without pretty-printers, grep `warning:`, with a known pre-existing warning as the control per the Swift rules) shows no warnings at changed lines.+ - Blocked-by: m6b3h4c (Style guide, design doc and superseded requirements describe the shipped layout)
Q5's empty-remainder rule keeps the whole title when nothing follows the number, so the number shows twice. Spec-conformant; judge on the device.
CharacterExtractionUITests (9/9) ran on the final tree. WorkDetailActions, AccessibilityJourney, DuplicateResolution, ComposedSurface and M4ScaleRecent passed on the phase-4 tree; the review changes touch the fact gutter width, the spine's ForEach, and an accessibility container — none of the identifiers or frames those suites anchor on. make test-quick and make test-core did re-run green.
Two clean reruns followed; consistent with the store-digest / live-model flake families in docs/agent-notes/testing.md. Not reproduced.
The app link failed once on the old init(…rowTitle:) symbol until DerivedData/…/AsterismCore.build was cleared — the documented trap. CI from clean is unaffected.