The work name on an entry's title card becomes a control that opens the work. Eleven commits over main plus the fixes from this review, app-layer only: no Core, schema or repository change.
One rule decides the route. AppNavigation.openWork pushes only when the reader is in the Works tab and the entry is the chapter on top, keeping the series or creator screen beneath the work; everywhere else it replaces, matching the Stats and Check Library routes (Q2, Q7).
One property owns the blank-title rule. After this review, resolvedWorkTitle feeds both the drawn name and the link, so the card can no longer draw a blank name over a usable candidate and refuse to link it (Q8).
The title is the control. Serif face kept, a hidden chevron, an Open Work … label, the captured-page link's hit area preserved, and no disabled on a read-only entry (Q5, Q6).
The Merge sheet opts out through a per-host flag on the route wrapper, because a tab switch under a sheet is invisible (Q4).
Verification was fought for. Three full iPhone runs were ended from outside; the branch's coverage rests on their union plus per-suite runs, recorded in verification-run.md.
Ready to push
Four review agents found no correctness defect in the production code. Everything they raised was fixed in the working tree or deliberately skipped, and make test-quick exited 0 on the fixed tree with no compiler warnings. The branch's UI evidence is the honest part to weigh: every phone suite is green on the branch across three partial full-suite runs plus seven single-suite runs, the four new iPhone cases passed in every run that reached them, and the iPad target ran all 25 cases twice with the new case green both times but one different runner-crash failure each time, so no iPad run exited 0. The one added UI assertion, the Open Work Real Work label on the accessibility journey, was proven with a single boot-first run of that case. What remains is a push, which needs the owner's say.
working-tree Fixes applied in this review 8d46993 [doc]: entry-to-work-link verified; changelog, overview, testing counts 05db895 [feat]: iPad journey for the entry-to-work link 497800a [doc]: entry-to-work-link journeys name the fixture that carries a work 69bd9a8 [feat]: iPhone journeys for the entry-to-work link 4712817 [doc]: changelog and overview for entry-to-work-link phases 1 and 2 9fa4c04 [feat]: entry-to-work route keys on the Works tab; review fixes for phase 1 2de2edc [feat]: the entry title card's work name opens the work 4815f14 [feat]: navigation owns the entry-to-work route e15a739 [feat]: entry detail model exposes the work its title card can open 6d5c85b entry-to-work-link: task list for review 29054e9 entry-to-work-link: smolspec draft for review Every note (an "entry") in the app belongs to a work — a novel, a webtoon, a series of articles. The note's own screen has always named that work at the top, in a card above the note text. But the name was just words on a screen. A reader who had arrived at the note from the Recent list had no way to the work it belongs to except the Back button (which only helps if they came from the work in the first place) or walking to the Works tab and finding it by hand.
This change makes that name the thing you tap. It now has a small chevron (›) after it so it reads as tappable, and tapping it opens the work's own screen in the Works tab.
Two details decide whether it is tappable at all:
Tapping it does one of two things depending on where the reader is:
No data is changed by any of this. Opening a work writes nothing, so the link also works on a note that is temporarily read-only (see below).
The reader's most common route into a note is the Recent list, and the most common next question is "what is this a chapter of?" Before this change the answer was on the screen but the route to it was not; the reader had to re-find the work by name in another tab. One tap replaces that.
---
App layer only. AsterismCore, the schema and the repository are untouched; the two fields the feature needs (EntrySnapshot.workID and EntryTeachingDetail.workDisplayTitle) already existed and are already resolved by LibraryRepository+EntryDetail.swift.
Asterism/Asterism/ViewModels/EntryDetailModel.swift: a private resolvedWorkTitle (line 220) that owns "the detail read resolved a usable work name"; titleCardWorkContext (line 230) now reads it instead of the raw field; a new public linkedWorkID (line 243) returning entry?.workID only when that title exists.Asterism/Asterism/Layout/AppNavigation.swift: openWork(_:fromEntry:) (line 349), sitting between showWork and pushWork and dispatching to one of them. No new state, no new WorksRoute case.Asterism/Asterism/Views/EntryDetailView.swift: a new onOpenWork: ((UUID) -> Void)? (line 40, defaulted nil in the memberwise init as onResolveDuplicate is); a two-armed titleSection (line 332) whose linked arm is a .plain Button; a workNameText helper (line 397) shared by both arms.Asterism/Asterism/Layout/EntryDetailRoute.swift: a opensWork = true parameter (line 37) gating the closure it passes down (line 51).Asterism/Asterism/ContentView.swift:577: the Merge sheet's route passes opensWork: false. The six stack hosts (`CompactRootView.swift:112, 146, 156; WideRootView.swift:149, 227, 250`) take the default.AppNavigationTests, six in EntryDetailModelTests (plus a shared detail(...) fixture helper moved beside makeSUT), a new three-case EntryToWorkLinkUITests, one case each in AccessibilityJourneyUITests and WideLayoutUITests.CHANGELOG.md, specs/OVERVIEW.md, docs/agent-notes/testing.md counts, and verification-run.md.One rule per question, in the layer that owns the question. The model answers "is this name a link, and to what"; navigation answers "where does the work land"; the view answers "what does the control look like"; the route wrapper answers "does this host route at all". Nothing asks a second layer to repeat a rule.
linkedWorkID is deliberately not entry?.workID. The single condition is that the detail read resolved the work under a non-blank title, which folds three cases into one: no assignment at all, an assignment to a work with no rows behind it (fetchWorkGroup throws .recordNotFound and the repository returns nil), and a name that is present but blank. Because titleCardWorkContext now reads the same private property, the card cannot draw a name it will not link, nor skip a usable candidate title in favour of a blank resolved one — the Q8 fix, found in review.
AppNavigation.openWork(_:fromEntry:) is four lines over two existing methods:
``swift if selectedTab == .works, selectedWorkChapterEntryID == entryID { pushWork(workID) } else { showWork(workID) } ``
pushWork already drops a trailing .chapter and already refuses to append a work that is already on top, so the in-tab case needs no new logic: from [.series, .work(A), .chapter(e)] it yields [.series, .work(A)], and after a Move To it yields [.work(A), .work(B)]. showWork already replaces the path and clears selectedWorksEntryID, which is the out-of-tab contract every Stats and Check Library row already takes. The value of the new method is that the choice between them is one unit-tested rule rather than six call sites.
The condition is on selectedTab and the path (Q7). The Works stack is not reset when the reader leaves the tab, so it can be parked on the very chapter being opened from Recent; keying on the path alone would push onto a stack the reader never came in by.
In the view, the linked arm wraps the existing Text in a .plain-styled Button with a chevron.right marked accessibilityHidden(true), contentShape(Rectangle()) on the label, frame(minHeight: AsterismLayout.minHitTarget), the existing entry-detail-work-title identifier moved onto the button, and an Open Work \(work) accessibility label matching the Works list's convention (WorksView.swift:757). The Spacer(minLength: 0) between the name and the captured-page link glyph stays outside the button, so entry-detail-link keeps its own hit area — the one deviation from the WorkDetailView series-row pattern this control copies. There is no disabled modifier anywhere on it.
EntryDetailRoute.opensWork is the first per-host capability switch on that wrapper. It could not be folded into the existing showsSky, which is a layout fact about a column in a wide window; the Merge sheet is a compact-tree sheet that happens to be the one host with no valid tab switch.
WorksRoute case or a pendingRoute. Work detail's own onward routes (chapters, series, creators, merge) are all driven by worksPath, so a work pushed into Recent's own stack would have dead rows or need a second path. pendingRoute exists for Settings, which dismisses first; the Merge sheet has no reason to dismiss, so it opts out instead (Q2, Q4).Text becomes a Button, which changes the element type UI tests see; every query for it, old and new, goes through anyElement, so nothing broke.createWork and updateWork refuse a work title on.ColumnBackButton to that work, so that one screen carries two controls with one destination. Accepted by the spec's Risks section: the link is the same control on every host, and a chapter is the one place it is redundant rather than wrong.---
entry and teachingDetail are one read. load() assigns both in the same block (lines 252–253), and entry is written nowhere else but that method and the clearing path. So linkedWorkID's entry?.workID and resolvedWorkTitle's teachingDetail?.workDisplayTitle can never describe two different reads: the id the link carries is the id the title was resolved from. That is what makes "the name is a link exactly when the read resolved it" a safe one-line rule rather than a two-source agreement problem.
The dangling-reference case is resolved in Core, not in the view model. LibraryRepository+EntryDetail.swift:221–232 catches .recordNotFound from fetchWorkGroup and yields nil, mirroring the export's tolerance. linkedWorkID therefore inherits the store's definition of "present", including the group-carrier choice — a split work presents through one carrier row, so the name the card links is the name the work's own screen and the export show.
selectedWorkChapterEntryID rather than a literal route comparison. The review replaced worksPath.last == .chapter(entryID: entryID) with the existing computed property, which is defined as exactly that pattern match (AppNavigation.swift:112). Semantically identical today; the gain is that a future change to how a chapter is spelled on the path has one place to move.
The wide tree gets the route for free. The wide tree renders worksPath.last in its detail column and has no stack of its own for the Works pane, so showWork selecting the tab and setting a one-element path is what "switch the window to the Works pane showing this work" means there. The iPad journey asserts this rather than assuming it: it waits for works-list in the list column, then asserts work-detail-title is laid out inside wide-detail-column, which is what tells this route from a push across the pane.
Hit target versus hit shape. contentShape(Rectangle()) is applied to the HStack label, and frame(minHeight:) to the Button. The frame therefore guarantees the layout height, not that every point in it is hittable — the plain button's hit region is its label's content shape. At default text sizes the serif heading plus chevron is already close to 44 pt, and at AccessibilityXXXL it is far past it; the accessibility journey asserts the 44 pt floor and isHittable on the element the runtime publishes, which is the button's own frame, so the requirement is verified where it matters rather than by construction.
Accessibility element shape. The identifier and label sit on the Button, the chevron is accessibilityHidden(true), and the label is composed from the same string the Text draws — so VoiceOver reads the title exactly once, as Open Work {title}. The plain arm keeps the identifier on the Text with no explicit label, which is the pre-change behaviour. The UI tests exploit the asymmetry precisely: existence is queried through anyElement (type-agnostic), the tap through app.buttons[...], and the Merge case's whole assertion is XCTAssertFalse(app.buttons["entry-detail-work-title"].exists).
fixedSize(horizontal: false, vertical: true) was added to workNameText, so it now applies to the plain arm as well as the linked one. Inside a button label it is what keeps a long title wrapping rather than truncating (the series-row pattern's reason); on the plain arm it is a behaviour change the spec did not ask for, though a benign one — it only prevents the Text from being compressed below its ideal height by a surrounding HStack.
Fixture reality versus the Test Plan. The Test Plan named seeded-m1's Integration Chapter row; that fixture captures its entry and then creates Integration Work beside it with no move between the two, so the entry carries no workID and the card names nothing at all. All four phone journeys and the iPad journey moved to seeded-composed, whose two id.test chapters are composed-taught and carry Real Work. The Merge journey needs a third fixture, seeded-tolerated-divergentWorkSet, since only a divergent Work set publishes the resolve pill that opens the sheet. The smolspec's Test Plan was amended (commit 497800a) and both test files carry the explanation inline.
EntryDetailRoute now carries two per-host parameters, one layout (showsSky) and one capability (opensWork). The next per-host switch should be weighed against collapsing them into a host descriptor value; at two, separate booleans are still the smaller thing.EntryDetailView gains a third optional callback. All three default to nil, so previews and any future host compile unchanged and silently get no route — which is safe for this one (no link) but is a fail-open default for any future callback that must be present.AppNavigation now owns three work-opening routes (showWork, pushWork, openWork). openWork is the only one that takes context about the caller; if a second such route appears (an entry-to-series link, say) the shape to copy is this one, not a second boolean on a host.#if os-conditional, so the Mac gets it from the same source through the wide tree. PlatformSeamTests is untouched.selectedWorkChapterEntryID == entryID in the wide tree only — which would mean the view learning a navigation fact, so it should be resisted.opensWork is a fail-open default. A future host that must not route has to remember to pass false. The Merge sheet is the only such host today and the UI test pins it; a second one would argue for inverting the default or making the parameter non-defaulted.duplicate-reconciliation's pill moves or its identifier changes, testTheMergeSheetsChapterNamesItsWorkWithoutOfferingTheRoute fails for a reason unrelated to this feature.verification-run.md). No make test-ui or make test-ui-ipad run on this branch exited 0 — three phone runs were ended from outside and the iPad runner loses one case per full run to a crash. The four new phone cases passed in all three phone runs, the iPad case in both attempts that reached it, and the suites the phone runs never reached were re-run individually green. The gap is a single clean exit, not any evidence against the feature.---
Requirements are numbered in the order they appear in smolspec.md.
Fully implemented
linkedWorkID plus the Button arm, with no platform conditionals anywhere in the change. Unit-tested on the model, walked by journeys on iPhone and iPad. The Mac clause is structural rather than tested (see below).openWork(_:fromEntry:), with all five Test-Plan cases plus two more — the parked-chapter case from Q7 and a path topped by another entry's chapter — as unit tests. Every one of them also asserts selectedWorksEntryID == nil, which is the clause about the unattached-note selection being cleared with the stack.resolvedWorkTitle covering all three, with four unit tests (candidate fallback, empty, whitespace-only, nil workID) plus the Q8 case asserting the two properties agree on a blank input. The "absent work is tolerated" clause is resolved in Core and inherited.opensWork: false at ContentView.swift:577, asserted by EntryToWorkLinkUITests.testTheMergeSheetsChapterNamesItsWorkWithoutOfferingTheRoute on the exact distinction (the identifier exists, the button does not).disabled modifier on the control, and linkedWorkIDSurvivesAReadOnlyEntry pins the model half on a torn group. The rating buttons' own disabled state is untouched by the diff.Open Work {title} label, hidden chevron, trailing chevron): all five in titleSection and workNameText; the accessibility journey asserts the exact label string, and every existing query for the identifier (WorkDetailActionsUITests:235 and the new ones) resolves through anyElement. is outside the Button`, with a comment saying why.AccessibilityXXXL on iPhone): AccessibilityJourneyUITests.testTheEntryTitleCardsWorkNameOpensTheWorkAtLargestDynamicType, asserting the 44 pt floor, isHittable, the label, and the work that opens.Implemented, partially proven
EntryDetailView.swift touches only the stored property, the memberwise init, the work-name arm of titleSection and the new helper. What cannot be proven from the diff is layout: turning a Text into a Button with a minHeight inside a firstTextBaseline HStack can change the card's height and therefore the spacing below it. No test asserts the card's geometry, and the branch's visual check is the owner's.#if os and the shared wide tree make it structurally true, but there is no Mac UI-test target and the branch's make build-mac proves compilation only. This is the standing project situation, not a gap this feature introduced.make test-ui or make test-ui-ipad invocation on this branch exited 0. See verification-run.md; make test-quick exited 0 with no new warnings on every commit.Missing
Divergences from the Implementation Approach
selectedWorkChapterEntryID == entryID rather than the smolspec's literal `worksPath.last == .chapter(entryID: entryID)`. Semantically identical — the computed property is that pattern match — and a review improvement, but the smolspec's bullet still spells the literal comparison and was not amended.workNameText adds fixedSize(horizontal: false, vertical: true) to both arms. The shared helper is in the amended smolspec; the fixedSize is not mentioned in any requirement, and it changes the plain arm's layout behaviour, however slightly. Justified in the code comment as the series-row pattern's wrapping guarantee under Req 6.seeded-m1 to seeded-composed (and a third, seeded-tolerated-divergentWorkSet, for the Merge case). The smolspec was amended in 497800a and both test files explain why, so this is a recorded correction rather than an open divergence.Follow-ups noted, not built
showsSky and opensWork into one host descriptor if a third per-host parameter appears.opensWork's fail-open default should invert once a second non-routing host exists.make test-ui and make test-ui-ipad on a quiet, unlocked host.Asterism/Asterism/Layout/AppNavigation.swift
Why it matters. The whole feature's behaviour is these four lines; the tab half of the condition is the non-obvious part and came out of the phase review.
What to look at. AppNavigation.swift:320-356, openWork; AppNavigationTests.swift:205-345
Asterism/Asterism/ViewModels/EntryDetailModel.swift
Why it matters. The one hunk that changed existing behaviour: with a blank resolved title the card now falls back to the candidate instead of drawing nothing.
What to look at. EntryDetailModel.swift:214-245
Asterism/Asterism/Views/EntryDetailView.swift
Why it matters. Identifier, label, hit area and the deliberate absence of disabled all live here; the Spacer outside the button is what keeps the captured-page link tappable.
What to look at. EntryDetailView.swift:329-370 and workNameText at 397
Asterism/Asterism/Layout/EntryDetailRoute.swift
Why it matters. First per-host capability flag on the route wrapper, defaulting to true; ContentView's Merge sheet is the one host that passes false.
What to look at. EntryDetailRoute.swift:32-54; ContentView.swift:570-578
Asterism/AsterismUITests/EntryToWorkLinkUITests.swift
Why it matters. The spec named a Recent row that has no work behind it; every journey moved to the composed-taught fixture whose chapters carry Real Work.
What to look at. EntryToWorkLinkUITests.swift header and cases; commit 497800a
specs/entry-to-work-link/verification-run.md
Why it matters. No full UI run exited 0. This document is the claim the reviewer is really being asked to accept.
What to look at. verification-run.md, sections 2 and 3
No schema, Core or repository change; every choice reverts with the branch (Q1).
Work detail's onward routes are all worksPath-driven, so a work screen in Recent's stack would have dead rows or need a second path (Q2).
A dangling workID is tolerated, not damage, and would open nothing; the candidate title names a parse, not a row (Q3).
A tab switch under a presented sheet is invisible; pendingRoute exists for Settings, which dismisses first (Q4).
A separate row would be a second thing to scan; the chevron is the only affordance change; the Spacer stays outside the button (Q5).
Opening a work writes nothing, and the work page is where a torn Work's resolve pill lives (Q6).
The Works stack survives leaving the tab and can be parked on the same chapter (Q7).
One private property owns the rule for both the drawn name and the link; found in this review (Q8).
Semantically identical; the file's own accessor for the chapter on top, so one place to change if the path spelling moves. Applied in this review from the reuse agent's finding.
(inferred — not stated by the author.)The series-row pattern's wrapping modifier, so a long title wraps inside a button label rather than truncating; applying it to the plain arm too is a consequence of sharing the helper.
(inferred — not stated by the author.)Each mirrors its neighbour: the route wrapper wires the route unless a host opts out, and the view takes no route unless handed one, so previews compile unchanged. Opposite fail directions one layer apart; deliberate here, but worth a comment if a third host appears.
(inferred — not stated by the author.)| Severity | Area | Finding | Resolution |
|---|---|---|---|
| medium | AppNavigation.openWork | Re-derived the chapter-on-top check inline although selectedWorkChapterEntryID already exists. | openWork now compares selectedWorkChapterEntryID to the entry id. |
| medium | EntryDetailModel blank title | titleCardWorkContext returned a blank workDisplayTitle while linkedWorkID refused it, so the card drew an empty name with the identifier and skipped a usable candidate. | A private resolvedWorkTitle owns the rule; both properties read it; Q8 recorded; a unit test pins the fallback. |
| medium | EntryDetailView title card | The serif Text stanza and its comment were duplicated in both arms of the card. | One workNameText helper serves both arms and carries the wrapping modifier. |
| low-medium | Req 6 wrapping | The Text inside the button carried neither fixedSize nor a line limit, the usual place a nested Text truncates. | fixedSize(horizontal: false, vertical: true) in the shared helper, per the cited series-row pattern. |
| medium | smolspec Requirement 2 | The parenthetical claimed the Works list's unattached-note selection is left as it was; showWork clears it. | Requirement reworded to say only Recent's stack survives. |
| low | smolspec citations | titleSection, ContentView and titleCardWorkContext line numbers had drifted; bullet 2 still spelled the inline path match. | Citations updated to the reviewed tree. |
| low | Req 6 test coverage | No test asserted the Open Work label or that the chevron is hidden from accessibility. | One assertion added to the largest-Dynamic-Type journey. |
| low | verification-run.md and testing.md counts | 29 phone suites should be 30; the bundle count read as if this branch added 15 cases; WorkCoverUITests listed as 8 cases, it has 10. | All three corrected. |
| medium-low | EntryDetailRoute host flags | showsSky and opensWork are two free booleans over three real hosts; a host enum would name the host instead. | Skipped: showsSky predates this branch and the enum touches seven call sites; a cleanup for its own change. |
| medium | Blank check helper | The project has M2Unicode.isBlank; the model uses Foundation's trimmed test. | Skipped on purpose: the trimmed test is the one the repository refuses a work title on, and the comment says so; isBlank would silently change the rule. |
| medium | Test duplication | The torn-variants fixture appears three times in EntryDetailModelTests; openWorkDetail and the Recent-row predicate are re-implemented across UI suites. | Skipped: this review leaves test files alone unless a test is wrong; worth a follow-up to hoist openWorkDetail and recentRow(naming:) into UIJourneySupport. |
| low | Req 7 and Req 5 view coverage | No test exercises the captured-page link's hit area beside a linked name, or the button staying enabled on a read-only entry at the view level. | Skipped: model-level coverage exists for Req 5; Req 7 is a manual check on a phone. |
Source: local run at 2026-09-16T17:00:17+10:00 · snapshot 8d46993 (dirty working tree)
Baseline: none
Execution: passed · JUnit: none · Coverage: none · Baseline: absent
Coverage scope: as the project configures it
The test runner could not be detected.
Derived by declaration name, from the diff (no baseline run).
Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 99a1c2b3fc88b65237309e281273b9af27f56c02.
Click to expand.
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex 7d678e4..71372d9 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -570,8 +570,11 @@ struct ContentView: View { exportModel: model.markdownExportModel(forWork: presented.id), showsSky: false) .navigationDestination(item: $navigation.mergeSheetEntryID) { entryID in+ // `entry-to-work-link` Q4: no work route under a+ // presented sheet, which is already showing it. EntryDetailRoute(- model: model, navigation: navigation, entryID: entryID)+ model: model, navigation: navigation, entryID: entryID,+ opensWork: false) } } }
diff --git a/Asterism/Asterism/Layout/AppNavigation.swift b/Asterism/Asterism/Layout/AppNavigation.swiftindex 3d12d7d..4e40d66 100644--- a/Asterism/Asterism/Layout/AppNavigation.swift+++ b/Asterism/Asterism/Layout/AppNavigation.swift@@ -317,6 +317,43 @@ final class AppNavigation { worksPath.append(.work(workID)) } + /// The entry detail title card's route to the work its note belongs to+ /// (Q2 of `specs/entry-to-work-link`).+ ///+ /// The two halves are one rule, so they are one method rather than a choice+ /// each host makes for itself — the rule is unit-tested here instead of+ /// spread over six call sites that can drift.+ ///+ /// **When the reader is in the Works tab and the entry is the chapter on top+ /// of the Works path, it pushes.** The reader is already in this stack, and+ /// ``showWork(_:)`` would replace the whole path — a chapter reached through+ /// a series or a creator screen would lose that screen, and Back would land+ /// on the Works list rather than where the reader came in. ``pushWork(_:)``+ /// drops the trailing chapter and refuses to append the work already under+ /// it (its own guard, pinned by+ /// `AppNavigationTests.pushWorkOnTheSameWorkOnlyDropsTheChapter`), which is+ /// exactly this route's outcome: the chapter's own work, with everything+ /// beneath it still in place. After a Move To the same call opens the *new*+ /// work on top of the old one, so Back still returns to the work the reader+ /// came through.+ ///+ /// **Everywhere else it replaces**, and the tab is half of "everywhere+ /// else": the Works tab can be parked on this very entry's chapter while the+ /// reader opens the entry from Recent, and Req 2 promises that reader a work+ /// with nothing beneath it. So the push is keyed on `selectedTab` as well as+ /// on the path (Q7). ``showWork(_:)`` is the route every out-of-tab caller+ /// already takes — a Stats breakdown row, a Check Library row. Only Recent's+ /// own stack is left as it was; the Works list's unattached-note route and+ /// another entry's chapter both live on the Works stack, which this replaces,+ /// clearing ``selectedWorksEntryID`` with it.+ func openWork(_ workID: UUID, fromEntry entryID: UUID) {+ if selectedTab == .works, selectedWorkChapterEntryID == entryID {+ pushWork(workID)+ } else {+ showWork(workID)+ }+ }+ /// The chapter route: an entry opened from the chapter list *inside* a work. /// /// Only ever on top of a work — that is what makes it a chapter rather than
diff --git a/Asterism/Asterism/Layout/EntryDetailRoute.swift b/Asterism/Asterism/Layout/EntryDetailRoute.swiftindex 8bce1ae..593a229 100644--- a/Asterism/Asterism/Layout/EntryDetailRoute.swift+++ b/Asterism/Asterism/Layout/EntryDetailRoute.swift@@ -29,6 +29,12 @@ struct EntryDetailRoute: View { /// Req 3.1: false where this screen is a column of a window that already /// paints one sky behind all of them. var showsSky = true+ /// `entry-to-work-link` Q4: false where the title card's work name must+ /// stay plain text. The one host that says so is the Merge sheet, which+ /// stays presented over a tab switch and is already showing the work the+ /// route would open. This is a host *capability*, which is why it cannot be+ /// derived from `showsSky` — that one is a layout fact.+ var opensWork = true var body: some View { if let detailModel = model.entryDetailModel(for: entryID) {@@ -40,6 +46,10 @@ struct EntryDetailRoute: View { onResolveDuplicate: navigation.resolveRoute( for: entryID, type: .entry, workload: { model.recentPresentation.duplicateWorkload }),+ // `entry-to-work-link` Req 2: navigation owns the rule about+ // where the work lands, so every host passes the same closure.+ onOpenWork: opensWork+ ? { navigation.openWork($0, fromEntry: entryID) } : nil, exportModel: model.markdownExportModel(forEntry: entryID), showsSky: showsSky, // T-2301: the two signals are one fact about the host. A pushed
diff --git a/Asterism/Asterism/ViewModels/EntryDetailModel.swift b/Asterism/Asterism/ViewModels/EntryDetailModel.swiftindex e874f91..39ab809 100644--- a/Asterism/Asterism/ViewModels/EntryDetailModel.swift+++ b/Asterism/Asterism/ViewModels/EntryDetailModel.swift@@ -207,11 +207,41 @@ public final class EntryDetailModel { entry == nil ? "Entry" : titleCardHeading } + /// The work's resolved name, or nil where there is none to show: the detail+ /// read found no work, or the name it found is blank. Blank rather than+ /// empty, because the repository refuses a whitespace-only work title on+ /// the same trimmed test (`createWork` and `updateWork`), so a name that is+ /// all spaces is one no writer could have meant — it can only arrive by+ /// sync or import from another build (`entry-to-work-link` Q3, Q8).+ ///+ /// One property owns that rule so the two below cannot disagree: the card+ /// never draws a blank name over a usable candidate, and it never links a+ /// name it does not draw.+ private var resolvedWorkTitle: String? {+ guard let title = teachingDetail?.workDisplayTitle,+ !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty+ else { return nil }+ return title+ }+ /// The work the heading belongs to, where the parse produced one. Falls back /// to the replayed candidate title, which is what Recent labels an entry with /// when its assignment did not settle. public var titleCardWorkContext: String? {- teachingDetail?.workDisplayTitle ?? teachingDetail?.unresolvedCandidateTitle+ resolvedWorkTitle ?? teachingDetail?.unresolvedCandidateTitle+ }++ /// The work the title card's name can open, and nil when the name is not a+ /// link (`entry-to-work-link` Requirements 1 and 3, Q3).+ ///+ /// The single rule is that the detail read *resolved* the work: a `workID`+ /// naming a work with no rows behind it is a tolerated state, not damage,+ /// and a link to it would open nothing. The candidate-title fallback names+ /// a parse rather than a row, and a blank resolved name would render as a+ /// bare chevron. Nothing here consults ``isReadOnly``: opening a work+ /// writes nothing, so a torn entry keeps the way out (Q6).+ public var linkedWorkID: UUID? {+ resolvedWorkTitle == nil ? nil : entry?.workID } public func load() async {
diff --git a/Asterism/Asterism/Views/EntryDetailView.swift b/Asterism/Asterism/Views/EntryDetailView.swiftindex b3f4d9a..94dc2ff 100644--- a/Asterism/Asterism/Views/EntryDetailView.swift+++ b/Asterism/Asterism/Views/EntryDetailView.swift@@ -33,6 +33,11 @@ struct EntryDetailView: View { /// Req 9.2's route out of a torn record, or nil where the caller has no /// sheet to open (previews, and the surfaces that host this without one). let onResolveDuplicate: (() -> Void)?+ /// `entry-to-work-link` Req 1: the route from the title card's work name to+ /// that work, or nil where this host must not route — the Merge sheet,+ /// which stays presented over a tab switch and is already showing the work+ /// (Q4).+ let onOpenWork: ((UUID) -> Void)? /// `ipad-and-mac-layouts` Req 3.1: false in the wide tree, where this screen /// is a column of a window that already paints one sky behind all of them. let showsSky: Bool@@ -48,11 +53,13 @@ struct EntryDetailView: View { model: EntryDetailModel, onMoveTo: @escaping () -> Void, onResolveDuplicate: (() -> Void)? = nil,+ onOpenWork: ((UUID) -> Void)? = nil, exportModel: MarkdownExportModel? = nil, showsSky: Bool = true, leavesAfterUpdate: Bool = true ) { self.onResolveDuplicate = onResolveDuplicate+ self.onOpenWork = onOpenWork self.showsSky = showsSky self.leavesAfterUpdate = leavesAfterUpdate _model = State(initialValue: model)@@ -322,13 +329,39 @@ struct EntryDetailView: View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 8) { if let work = model.titleCardWorkContext {- // §3: a work title is the serif face.- Text(work)- .font(AsterismTypography.serifHeading)- .foregroundStyle(AsterismColors.primaryText)+ if let workID = model.linkedWorkID, let onOpenWork {+ // `entry-to-work-link` Req 1 and Q5: the name+ // itself is the control, on the series-row pattern+ // in `WorkDetailView`. The chevron is the only+ // affordance added and is hidden from+ // accessibility, so the title is read once. No+ // `disabled`: opening a work writes nothing, so a+ // read-only entry and an in-flight update both keep+ // the way out (Q6), unlike the rating buttons below.+ Button {+ onOpenWork(workID)+ } label: {+ HStack(alignment: .firstTextBaseline, spacing: 6) {+ workNameText(work)+ Image(systemName: "chevron.right")+ .font(.caption)+ .foregroundStyle(AsterismColors.secondaryText)+ .accessibilityHidden(true)+ }+ .contentShape(Rectangle())+ }+ .buttonStyle(.plain)+ .frame(minHeight: AsterismLayout.minHitTarget) .accessibilityIdentifier("entry-detail-work-title")+ .accessibilityLabel("Open Work \(work)")+ } else {+ workNameText(work)+ .accessibilityIdentifier("entry-detail-work-title")+ } } + // Q5: outside the button, so `entry-detail-link` keeps its+ // own hit area rather than being swallowed by the name's. Spacer(minLength: 0) if let url = URL(string: entry.rawURLString) {@@ -357,6 +390,17 @@ struct EntryDetailView: View { } } + /// §3: a work title is the serif face. One helper for both arms of the+ /// title card, so the linked and the plain name cannot drift apart; the+ /// `fixedSize` is the series-row pattern's, so a long title wraps inside a+ /// button label rather than truncating (`entry-to-work-link` Req 6).+ private func workNameText(_ work: String) -> some View {+ Text(work)+ .font(AsterismTypography.serifHeading)+ .foregroundStyle(AsterismColors.primaryText)+ .fixedSize(horizontal: false, vertical: true)+ }+ /// `character-extraction` Req 5.4: who this note is about, where any /// character says so. Absent rather than empty — most notes name nobody the /// reader has kept, and an empty section on every entry would be noise on
diff --git a/Asterism/AsterismTests/AppNavigationTests.swift b/Asterism/AsterismTests/AppNavigationTests.swiftindex cdf1f6d..0702aaf 100644--- a/Asterism/AsterismTests/AppNavigationTests.swift+++ b/Asterism/AsterismTests/AppNavigationTests.swift@@ -202,6 +202,148 @@ struct AppNavigationTests { #expect(navigation.worksPath == [.seriesList]) } + // MARK: - The entry-to-work route (Q2 of `entry-to-work-link`)++ /// The whole point of the chapter half: the screen the chapter was reached+ /// *through* survives. `showWork` would replace the path and Back would land+ /// on the Works list rather than on the series the reader came in by.+ @Test("openWork from a chapter keeps the series the chapter was reached through")+ func openWorkFromAChapterKeepsTheStackBeneathIt() {+ let navigation = AppNavigation()+ let seriesID = UUID()+ let workID = UUID()+ let entryID = UUID()+ navigation.showSeries(seriesID)+ navigation.pushWork(workID)+ navigation.showChapter(entryID)+ navigation.selectedWorksEntryID = UUID()++ navigation.openWork(workID, fromEntry: entryID)++ #expect(+ navigation.worksPath == [+ .series(id: seriesID, originWorkID: nil), .work(workID),+ ])+ #expect(navigation.selectedWorkChapterEntryID == nil, "the chapter was left")+ #expect(navigation.selectedWorksEntryID == nil)+ }++ /// The same route with nothing under the work: the chapter goes and the work+ /// it belongs to is what is left, rather than a second copy of it pushed on+ /// top.+ @Test("openWork from a chapter leaves the work the chapter belongs to")+ func openWorkFromAChapterLeavesItsOwnWork() {+ let navigation = AppNavigation()+ let workID = UUID()+ let entryID = UUID()+ navigation.showWork(workID)+ navigation.showChapter(entryID)+ navigation.selectedWorksEntryID = UUID()++ navigation.openWork(workID, fromEntry: entryID)++ #expect(navigation.worksPath == [.work(workID)])+ #expect(navigation.selectedWorkID == workID)+ #expect(navigation.selectedWorksEntryID == nil)+ }++ /// The entry was moved to another work while open as a chapter, so the work+ /// the title card names is no longer the one under it. The new work opens on+ /// top of the old, so Back returns to the work the reader came through.+ @Test("openWork from a moved chapter opens the new work over the old one")+ func openWorkFromAMovedChapterStacksTheNewWork() {+ let navigation = AppNavigation()+ let oldWork = UUID()+ let newWork = UUID()+ let entryID = UUID()+ navigation.showWork(oldWork)+ navigation.showChapter(entryID)+ navigation.selectedWorksEntryID = UUID()++ navigation.openWork(newWork, fromEntry: entryID)++ #expect(navigation.worksPath == [.work(oldWork), .work(newWork)])+ #expect(navigation.selectedWorkID == newWork)+ #expect(navigation.selectedWorksEntryID == nil)+ }++ /// From Recent the reader is outside the Works stack entirely, so this is+ /// the ordinary out-of-tab route: the tab is selected showing that one work+ /// with nothing beneath it, whatever the Works stack had been left on.+ @Test("openWork from Recent selects Works showing only that work")+ func openWorkFromRecentReplacesTheWorksStack() {+ let navigation = AppNavigation()+ let other = UUID()+ let workID = UUID()+ navigation.showWork(other)+ navigation.showSeries(UUID(), from: other)+ navigation.selectedTab = .recent+ navigation.selectedWorksEntryID = UUID()++ navigation.openWork(workID, fromEntry: UUID())++ #expect(navigation.selectedTab == .works)+ #expect(navigation.worksPath == [.work(workID)])+ #expect(navigation.selectedWorksEntryID == nil)+ }++ /// Q7, and the reason the push is keyed on the tab as well as on the path:+ /// the Works tab can be parked on this very entry's chapter while the reader+ /// opens the same entry from Recent. Req 2's out-of-tab clause promises that+ /// reader the work with nothing beneath it, so the path is replaced even+ /// though its top *is* this entry's chapter.+ @Test("openWork from Recent replaces a Works path parked on that entry's chapter")+ func openWorkFromRecentReplacesEvenItsOwnParkedChapter() {+ let navigation = AppNavigation()+ let seriesID = UUID()+ let workID = UUID()+ let entryID = UUID()+ navigation.showSeries(seriesID)+ navigation.pushWork(workID)+ navigation.showChapter(entryID)+ navigation.selectedTab = .recent+ navigation.selectedWorksEntryID = UUID()++ navigation.openWork(workID, fromEntry: entryID)++ #expect(navigation.selectedTab == .works)+ #expect(navigation.worksPath == [.work(workID)])+ #expect(navigation.selectedWorksEntryID == nil)+ }++ /// The push half is keyed on *this* entry's chapter, not on there being a+ /// chapter at all. A Works path topped by another work is a stack this entry+ /// was not reached through, so it is replaced.+ @Test("openWork replaces a Works path topped by another work")+ func openWorkReplacesAPathToppedByAnotherWork() {+ let navigation = AppNavigation()+ let workID = UUID()+ navigation.showWork(UUID())+ navigation.selectedWorksEntryID = UUID()++ navigation.openWork(workID, fromEntry: UUID())++ #expect(navigation.worksPath == [.work(workID)])+ #expect(navigation.selectedWorksEntryID == nil)+ }++ /// The same rule against some *other* entry's chapter: that chapter belongs+ /// to a work this entry was not reached through, so it goes with the path.+ @Test("openWork replaces a Works path topped by another entry's chapter")+ func openWorkReplacesAPathToppedByAnotherEntrysChapter() {+ let navigation = AppNavigation()+ let workID = UUID()+ navigation.showWork(UUID())+ navigation.showChapter(UUID())+ navigation.selectedWorksEntryID = UUID()++ navigation.openWork(workID, fromEntry: UUID())++ #expect(navigation.worksPath == [.work(workID)])+ #expect(navigation.selectedWorkChapterEntryID == nil)+ #expect(navigation.selectedWorksEntryID == nil)+ }+ /// Req 1.6's route, and Req 3.1's — both append, because both are opened /// from a screen that has to be there to come back to. @Test("showSeriesList and showSeries append, and the series carries its origin")
diff --git a/Asterism/AsterismTests/EntryDetailModelTests.swift b/Asterism/AsterismTests/EntryDetailModelTests.swiftindex df7789c..9f962ad 100644--- a/Asterism/AsterismTests/EntryDetailModelTests.swift+++ b/Asterism/AsterismTests/EntryDetailModelTests.swift@@ -42,6 +42,31 @@ struct EntryDetailModelTests { return (model, mock, tracker) } + /// A taught detail with the title-card fields spelled out, shared by the+ /// sections that ask what the card *says* and what its name *opens*. It sits+ /// beside `makeSUT` rather than at the tail of either one, so neither MARK+ /// appears to own it.+ private static func detail(+ for entry: EntrySnapshot,+ displayTitle: String,+ workDisplayTitle: String? = nil,+ unresolvedCandidateTitle: String? = nil,+ groupState: RecordGroupState<EntryAuthoredContent> = .single+ ) -> EntryTeachingDetail {+ EntryTeachingDetail(+ entry: entry,+ siteMode: .taught,+ activePatternSummary: nil,+ historicalPatternSummaries: [],+ chapterSettlement: .unsettled(reason: "test"),+ assignmentSettlement: .unsettled(reason: "test"),+ availableActions: [],+ unresolvedCandidateTitle: unresolvedCandidateTitle,+ displayTitle: displayTitle,+ workDisplayTitle: workDisplayTitle,+ groupState: groupState)+ }+ /// The Delete button followed by its confirmation, which is what a reader's /// two taps amount to since Q50. Nothing here re-reads model state: the /// prompt the dialog rendered is what the commit takes.@@ -460,23 +485,125 @@ struct EntryDetailModelTests { #expect(model.titleCardWorkContext == "Some Story") } - private static func detail(- for entry: EntrySnapshot,- displayTitle: String,- workDisplayTitle: String? = nil,- unresolvedCandidateTitle: String? = nil- ) -> EntryTeachingDetail {- EntryTeachingDetail(+ /// A blank resolved name is no resolved name (Q8): the card falls back to+ /// the candidate rather than drawing nothing, and the two properties agree.+ @Test("A blank resolved title falls back to the candidate and is not a link")+ @MainActor func workContextTreatsABlankTitleAsUnresolved() async {+ let entry = TestFixtures.makeEntry(chapterTitle: "Chapter 3")+ let (model, _, _) = makeSUT( entry: entry,- siteMode: .taught,- activePatternSummary: nil,- historicalPatternSummaries: [],- chapterSettlement: .unsettled(reason: "test"),- assignmentSettlement: .unsettled(reason: "test"),- availableActions: [],- unresolvedCandidateTitle: unresolvedCandidateTitle,- displayTitle: displayTitle,- workDisplayTitle: workDisplayTitle)+ detail: Self.detail(+ for: entry, displayTitle: entry.captureTitle,+ workDisplayTitle: " \n ",+ unresolvedCandidateTitle: "Some Story"))++ await model.load()++ #expect(model.titleCardWorkContext == "Some Story")+ #expect(model.linkedWorkID == nil)+ }++ // MARK: - Which work the title card can open (entry-to-work-link Req 1, 3)++ /// The name is a link exactly when the detail read resolved the work, which+ /// is the one rule the view asks about (Q3).+ @Test("The title card links the work when the detail resolved its title")+ @MainActor func linkedWorkIDIsPresentWhenTheWorkResolves() async {+ let workID = UUID()+ let entry = TestFixtures.makeEntry(workID: workID, chapterTitle: "Chapter 12")+ let (model, _, _) = makeSUT(+ entry: entry,+ detail: Self.detail(+ for: entry, displayTitle: "Chapter 12", workDisplayTitle: "Some Story"))++ await model.load()++ #expect(model.linkedWorkID == workID)+ }++ /// Q6: opening a work writes nothing, and the work page is where a torn+ /// Work's own resolve pill lives — so a read-only entry keeps the way out.+ @Test("A read-only entry still links its work")+ @MainActor func linkedWorkIDSurvivesAReadOnlyEntry() async {+ let workID = UUID()+ let entry = TestFixtures.makeEntry(workID: workID)+ let (model, _, _) = makeSUT(+ entry: entry,+ detail: Self.detail(+ for: entry, displayTitle: "Chapter 12", workDisplayTitle: "Some Story",+ groupState: .torn(variants: [+ AuthoredVariant(+ content: EntryAuthoredContent(note: "leading"),+ firstCapturedAt: TestFixtures.fixedDate),+ AuthoredVariant(+ content: EntryAuthoredContent(note: "the other device"),+ firstCapturedAt: TestFixtures.fixedDate),+ ])))++ await model.load()++ #expect(model.isReadOnly)+ #expect(model.linkedWorkID == workID)+ }++ /// The candidate fallback names a parse, not a row, so there is nothing to+ /// open even though the card still shows a work name.+ @Test("The candidate-title fallback is not a link")+ @MainActor func linkedWorkIDIsAbsentForTheCandidateFallback() async {+ let entry = TestFixtures.makeEntry(workID: UUID())+ let (model, _, _) = makeSUT(+ entry: entry,+ detail: Self.detail(+ for: entry, displayTitle: "Chapter 12",+ unresolvedCandidateTitle: "Some Story"))++ await model.load()++ #expect(model.titleCardWorkContext == "Some Story")+ #expect(model.linkedWorkID == nil)+ }++ /// An empty resolved name would render as a bare chevron (Q3).+ @Test("An empty resolved work title is not a link")+ @MainActor func linkedWorkIDIsAbsentForAnEmptyTitle() async {+ let entry = TestFixtures.makeEntry(workID: UUID())+ let (model, _, _) = makeSUT(+ entry: entry,+ detail: Self.detail(+ for: entry, displayTitle: "Chapter 12", workDisplayTitle: ""))++ await model.load()++ #expect(model.linkedWorkID == nil)+ }++ /// Blank, not merely empty: the repository refuses a whitespace-only work+ /// title on the same trimmed test, so a name made of spaces is one no writer+ /// could have meant and would render as a bare chevron just the same.+ @Test("A whitespace-only resolved work title is not a link")+ @MainActor func linkedWorkIDIsAbsentForABlankTitle() async {+ let entry = TestFixtures.makeEntry(workID: UUID())+ let (model, _, _) = makeSUT(+ entry: entry,+ detail: Self.detail(+ for: entry, displayTitle: "Chapter 12", workDisplayTitle: " \n "))++ await model.load()++ #expect(model.linkedWorkID == nil)+ }++ @Test("An entry with no work assignment is not a link")+ @MainActor func linkedWorkIDIsAbsentWithoutAWorkID() async {+ let entry = TestFixtures.makeEntry(workID: nil)+ let (model, _, _) = makeSUT(+ entry: entry,+ detail: Self.detail(+ for: entry, displayTitle: "Chapter 12", workDisplayTitle: "Some Story"))++ await model.load()++ #expect(model.linkedWorkID == nil) } // MARK: - Why there is no entry to show
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex f98b5c4..cf929ba 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -1139,6 +1139,58 @@ final class AccessibilityJourneyUITests: XCTestCase { } } + /// `entry-to-work-link`'s last requirement: at the largest text size the+ /// title card's work name is still a control a reader can hit, and it still+ /// opens the work.+ ///+ /// **`seeded-composed` rather than this suite's `seeded-m1`.** That fixture+ /// captures its entry and then creates `Integration Work` beside it with no+ /// move between the two, so the entry carries no `workID`, the detail read+ /// resolves no work title, and the card names nothing to link — which is why+ /// the journey at the top of this file reaches that work through Move To.+ /// The `seeded-composed` chapters are composed-taught and carry their Work.+ /// The three default-size journeys for this control are+ /// `EntryToWorkLinkUITests`'.+ @MainActor+ func testTheEntryTitleCardsWorkNameOpensTheWorkAtLargestDynamicType() {+ launchSeeded(+ scenario: "seeded-composed",+ extraArguments: [+ "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"+ ]+ )++ XCTAssertTrue(+ app.collectionViews["recent-list"].waitForExistence(timeout: 60), "The library opens")+ // The taught chapter rather than the untaught capture beside it: only a+ // resolved work is a link (Q3).+ let chapter = app.buttons.matching(+ NSPredicate(+ format: "identifier BEGINSWITH 'recent-entry-' AND label CONTAINS[c] 'Real Work'")+ ).firstMatch+ XCTAssertTrue(chapter.waitForExistence(timeout: 20), "Recent lists a taught chapter")+ scrollToElement(chapter, attempts: 8)+ chapter.tap()++ // The card is the first thing on the screen, so this is a content+ // control at the top of a `Form` rather than one below a fold — but at+ // this size the title alone can be most of the screen, so it is scrolled+ // to like every other one.+ let workName = app.buttons["entry-detail-work-title"]+ XCTAssertTrue(+ workName.waitForExistence(timeout: 20), "The card names the work as a control")+ scrollToElement(workName, attempts: 4)+ assertContentControl(workName, named: "The title card's work name at largest Dynamic Type")+ // Req 6: the control speaks the Works list's "Open Work …" form, and+ // the chevron is hidden from accessibility so the title is read once.+ XCTAssertEqual(workName.label, "Open Work Real Work", "The control says what it opens")++ workName.tap()+ let workTitle = app.anyElement("work-detail-title")+ XCTAssertTrue(workTitle.waitForExistence(timeout: 20), "Tapping it opens the work")+ XCTAssertEqual(workTitle.label, "Real Work", "…the one the card named")+ }+ /// Taps a review-list control, walking back to the top of the list first. /// /// `scrollUntilTappableAndTap` only ever goes one way, and a decision taken
diff --git a/Asterism/AsterismUITests/EntryToWorkLinkUITests.swift b/Asterism/AsterismUITests/EntryToWorkLinkUITests.swiftnew file mode 100644index 0000000..f7560bf--- /dev/null+++ b/Asterism/AsterismUITests/EntryToWorkLinkUITests.swift@@ -0,0 +1,168 @@+import XCTest++/// `entry-to-work-link`: the entry title card's work name, driven from app+/// launch.+///+/// Three of the four phone journeys the smolspec's Test Plan names — the route+/// in from Recent, the route in from the work's own chapter row, and the Merge+/// sheet where the name must stay plain text (Q4). The fourth, the largest+/// Dynamic Type size, is `AccessibilityJourneyUITests`', where every other text+/// size journey lives and where the 44 pt helpers are.+///+/// **The fixture is `seeded-composed`, not `seeded-m1`.** The Test Plan named+/// the seeded scenario's `Integration Work` row, but that fixture captures its+/// entry and then creates the Work beside it with no move between the two — so+/// the entry carries no `workID`, the detail read resolves no work title, and+/// the card names nothing at all (which is why `AccessibilityJourneyUITests`+/// reaches that work through Move To). `seeded-composed`'s two `id.test`+/// chapters are composed-taught and carry their Work, which is the shape a link+/// needs: `linkedWorkID` is exactly "the detail read resolved the work" (Q3).+///+/// The control is queried type-agnostically for existence and tapped through+/// the button query, which is the whole assertion in the Merge case: it exists+/// either way, and only one of the two is a `Button`.+final class EntryToWorkLinkUITests: XCTestCase {+ let app = XCUIApplication()++ override func setUp() {+ continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait+ terminateAndWaitForExit(app)+ }++ override func tearDown() {+ terminateAndWaitForExit(app)+ }++ // MARK: - Helpers++ /// Launched per case rather than in `setUp`, because the Merge journey needs+ /// a different library from the other two.+ private func launch(_ scenario: String) {+ app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = scenario+ app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+ app.launch()+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ }++ /// One Recent row, by the work title inside its accessibility label. The+ /// rows share the `recent-entry-` prefix, so the label is what tells the+ /// taught chapters from the untaught `composed.test` capture beside them.+ private func recentRow(naming fragment: String) -> XCUIElement {+ app.buttons.matching(+ NSPredicate(+ format: "identifier BEGINSWITH 'recent-entry-' AND label CONTAINS[c] %@", fragment)+ ).firstMatch+ }++ /// Proves the name is on screen without assuming its element type, then taps+ /// it as the control it is where the route exists.+ private func tapWorkName(+ _ message: String, file: StaticString = #filePath, line: UInt = #line+ ) {+ waitFor(app.anyElement("entry-detail-work-title"), message, file: file, line: line)+ waitFor(+ app.buttons["entry-detail-work-title"], "\(message) — and it is a control",+ file: file, line: line+ ).tap()+ }++ /// The work detail screen, named.+ private func assertWorkDetailIsOnScreen(+ titled title: String, _ message: String,+ file: StaticString = #filePath, line: UInt = #line+ ) {+ let heading = waitFor(+ app.anyElement("work-detail-title"), message, timeout: 20, file: file, line: line)+ XCTAssertEqual(heading.label, title, "\(message) — the one the card named",+ file: file, line: line)+ waitFor(+ app.anyElement("work-detail-pulse"), "\(message) — the whole screen, not a fragment",+ file: file, line: line)+ }++ // MARK: - From Recent (Requirement 2's out-of-tab clause)++ /// A note reached from Recent is one tap from its whole work, and the work+ /// opens in the Works tab — the route every other out-of-tab caller takes.+ func testTheWorkNameOnAnEntryFromRecentOpensTheWorkInTheWorksTab() {+ launch("seeded-composed")++ waitFor(recentRow(naming: "Real Work"), "Recent lists a taught chapter").tap()+ waitFor(+ app.anyElement("entry-detail-note-editor"), "The row opened entry detail", timeout: 20)++ tapWorkName("The card names the work")++ // `work-detail-title` is drawn by the Works tab's stack and by nothing+ // else on screen here — no sheet is presented — so finding it is finding+ // the tab the route promised.+ assertWorkDetailIsOnScreen(titled: "Real Work", "The name opens the work")+ }++ // MARK: - From a chapter row (Requirement 2's in-tab clause)++ /// From the work's own chapter the route is the other one: the chapter is+ /// left behind and its work is what is on screen, rather than a second copy+ /// of the work pushed on top of it.+ func testTheWorkNameOnAChapterLeavesTheChapterOnItsWork() {+ launch("seeded-composed")++ selectTab(.works, in: app)+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+ waitFor(+ app.elements(withIdentifierPrefix: "work-row-").firstMatch, "The taught work is listed"+ ).tap()+ waitFor(app.anyElement("work-detail-pulse"), "Work detail presents the rating pulse")++ waitFor(app.anyElement("work-detail-entry"), "The chapter list is on screen").tap()+ waitFor(+ app.anyElement("entry-detail-note-editor"), "The chapter row opened entry detail",+ timeout: 20)++ tapWorkName("The chapter's card names the work it came from")++ waitUntilGone(+ app.anyElement("entry-detail-note-editor"), "The chapter is left rather than stacked on")+ XCTAssertFalse(+ app.buttons["entry-detail-update-button"].exists,+ "…the whole of it, not just its note")+ assertWorkDetailIsOnScreen(titled: "Real Work", "…and the work is what is on screen")+ XCTAssertFalse(+ app.collectionViews["works-list"].exists,+ "Leaving a chapter for its work does not pop past the work")+ }++ // MARK: - The Merge sheet (Q4)++ /// Inside the Merge sheet the name is text. A tab switch under a presented+ /// sheet is invisible, and the work it would open is the one the sheet is+ /// already showing.+ ///+ /// `seeded-tolerated-divergentWorkSet` is the library that has a Merge in+ /// it: two `Twinned Serial` rows sharing one URL identity, each carrying one+ /// `Serial Chapter` note, and each wearing the Works tab's resolve pill that+ /// routes into the sheet (`duplicate-reconciliation` Q30).+ func testTheMergeSheetsChapterNamesItsWorkWithoutOfferingTheRoute() {+ launch("seeded-tolerated-divergentWorkSet")++ selectTab(.works, in: app)+ waitFor(+ app.buttons.matching(identifier: "work-resolve-duplicate-pill").firstMatch,+ "A divergent Work set offers the way into the Merge"+ ).tap()+ waitFor(app.anyElement("work-detail-pulse"), "The Merge sheet opens on the work", timeout: 20)++ scrollUntilTappableAndTap(+ app.anyElement("work-detail-entry"), in: app,+ "The sheet's chapter rows open the notes they list")+ waitFor(+ app.anyElement("entry-detail-note-editor"), "The chapter row opened entry detail",+ timeout: 20)++ waitFor(app.anyElement("entry-detail-work-title"), "The card still names the work")+ XCTAssertFalse(+ app.buttons["entry-detail-work-title"].exists,+ "Q4: under a presented sheet the name is text, not a route")+ }+}
diff --git a/Asterism/AsterismUITests/WideLayoutUITests.swift b/Asterism/AsterismUITests/WideLayoutUITests.swiftindex 6efbdd7..b6cf1cb 100644--- a/Asterism/AsterismUITests/WideLayoutUITests.swift+++ b/Asterism/AsterismUITests/WideLayoutUITests.swift@@ -404,6 +404,57 @@ final class WideLayoutUITests: XCTestCase { "…and the chapter is gone from the column") } + // MARK: - `entry-to-work-link` — the detail column's route to the work++ /// The title card's work name, from the Recent pane's detail column+ /// (`entry-to-work-link`, Test Plan's iPad bullet). Requirement 2's+ /// out-of-tab clause in the wide tree: the entry was opened from Recent, so+ /// the name selects the **Works pane** showing that one work, rather than+ /// pushing anything onto the stack the entry came from.+ ///+ /// This is the assumption the smolspec's Risks section records — that+ /// `showWork` selects the Works pane and the detail column renders+ /// `worksPath.last`, as the chapter case above relies on — asserted rather+ /// than assumed: `works-list` beside the pane's detail column is what "the+ /// window is on Works" means here, and the work laid out *inside* that+ /// column is what tells this route from Req 1.7's push across the pane.+ ///+ /// `seeded-composed` again, and for the same reason the phone journeys use+ /// it: its two `id.test` chapters are composed-taught and carry the work+ /// `Real Work`, which is what makes the card name anything at all.+ func testTheWorkNameInTheDetailColumnOpensTheWorkOnTheWorksPane() {+ launch("seeded-composed", orientation: .landscapeLeft)+ waitForLibrary()++ let row = waitFor(+ recentRows.matching(NSPredicate(format: "label CONTAINS[c] %@", "Real Work"))+ .firstMatch,+ "Recent lists a composed-taught chapter")+ row.tap()+ waitFor(+ app.anyElement("entry-detail-note-editor"),+ "The row opened entry detail in the detail column", timeout: 20)++ // Type-agnostic for existence, the button query for the tap: the name is+ // on screen either way and only the routed one is a control.+ waitFor(app.anyElement("entry-detail-work-title"), "The card names the work")+ waitFor(app.buttons["entry-detail-work-title"], "…and it is a control").tap()++ waitFor(+ app.collectionViews["works-list"],+ "The name switches the window to the Works pane", timeout: 20)+ let detailColumn = waitFor(+ app.anyElement("wide-detail-column"), "The detail column is laid out")+ let heading = waitFor(+ app.anyElement("work-detail-title"), "…with the work in the detail column",+ timeout: 20)+ XCTAssertEqual(heading.label, "Real Work", "…the one the card named")+ assertInsideColumn(heading, column: detailColumn, what: "The opened work")+ waitUntilGone(+ app.anyElement("entry-detail-note-editor"),+ "…and the pane the entry was read in is left behind")+ }+ /// The entry title out of a Recent row's accessibility label, which /// `RecentEntryRow` composes as "Open entry {title} from {hostname}". private static func entryTitle(fromRowLabel label: String) -> String {
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d506925..e1d0509 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -125,6 +125,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **An entry's title card opens its work (`entry-to-work-link`).** The+ work name on the entry detail screen was plain text; it is+ now a control with a trailing chevron that opens the work's detail+ screen, so a note reached from Recent is one tap from the whole work.+ The name is a link only when the detail read resolved the work under a+ non-blank title (Q3); the candidate-title fallback, a work not in the+ library, and the Merge sheet's copy of the screen keep plain text (Q4).+ The route is one navigation-owned method: when the reader is in the+ Works tab and the entry is the chapter on top of the Works path, the+ chapter is dropped and whatever sits beneath the work — a series, a+ creator, an origin work — stays, so Back still returns there; from any+ other tab the Works tab is selected showing that one work with nothing+ beneath it, whatever the Works path held (Q2, Q7). The control stays+ enabled on a read-only entry, because opening a work writes nothing+ (Q6). `EntryDetailModel.linkedWorkID`, `AppNavigation.openWork`,+ `EntryDetailView.onOpenWork`, `EntryDetailRoute.opensWork`; no Core,+ schema or repository change. Four iPhone journeys (Recent, a chapter+ under its work, the largest Dynamic Type, the Merge sheet's plain text)+ and one iPad journey cover it; `specs/entry-to-work-link/verification-run.md`+ records the runs. - **Work thumbnails: every cover drawn the same way, and a cheaper export (T-2330, pre-push review of 2026-09-14).** Three surfaces drew covers their own way and two of them drew them wrong. The Cover
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex d33b440..9e5543d 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -739,8 +739,9 @@ exists". (Req 8.3) are about a *regular-width* window and cannot pass on the phone — there is no sidebar there to find. They share the one UI-test bundle with the phone journeys, so `make test` and `make test-ui` name them in `IPAD_ONLY_SUITES` and-skip them; `make test-ui-ipad` is where they run — **24** cases, 22 of them-`WideLayoutUITests`' and 2 the accessibility class's.+skip them; `make test-ui-ipad` is where they run — **25** cases since+`entry-to-work-link`, 23 of them `WideLayoutUITests`' and 2 the accessibility+class's. If you add another regular-width suite, add it to that variable **and** to `test-ui-ipad`'s `-only-testing` list. A suite that cannot pass on the@@ -1016,6 +1017,17 @@ gives 147; compare a run against the runner's total, not the grep), `XCTSkip` off a physical device — and three of them the seed-timeout failures above. `make test-ui-ipad` runs the other **24**. +`entry-to-work-link` (2026-09-16) moved four of those numbers: the per-class+grep gives **186** across the bundle (this branch's 5 cases on top of the 10+`work-thumbnails` had already added to the 171 above), `WideLayoutUITests` **23**,+`AccessibilityJourneyUITests` **15**, and a new three-case+`EntryToWorkLinkUITests` on the phone destination, so `make test-ui-ipad` runs+**25**. That branch also met both iPad failure shapes above in one morning: the+first attempt lost the runner mid-suite and never reached the new case, the+second ran all 25 with one runner crash recorded as a failure of+`testThePlacesSectionAndReviewKindControlInTheDetailColumn` (no assertion+line, an immediate suite restart), which had passed in the first attempt.+ Per suite, where this feature moved the number: `CharacterExtractionUITests` **15** (plus `CharacterExtractionOutcomeUITests`' 2 in the same file), `AccessibilityJourneyUITests` **14** (plus `WideLayoutAccessibilityUITests`' 2 in@@ -1026,7 +1038,7 @@ planned as a `WorkDetailRecordSessionTests` landed inside the existing `WorkDetailCharacterTests`, which is where the character drafts were already covered. -`work-thumbnails` adds `WorkCoverUITests` (**8** cases, phone, over a new+`work-thumbnails` adds `WorkCoverUITests` (**10** cases, phone, over a new `seeded-covered-work` scenario). The 2026-09-13 full run reported **155** on the phone destination — 150 plus five of them — still with two skipped, and all five passed; the review pass then added `testFindCoverSaysWhenTheDeviceIsOffline`, the
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex e622351..e79e1fc 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -43,6 +43,7 @@ | [Work Creators](#work-creators) | 2026-09-07 | Done | Full spec (T-2316). Creators and reader-defined ordered roles as directory tables on the work-types shape, and credits as join rows on the link shape, under schema V12 with markers `"11"` → `"12"` and the archive at 11/12. Creators list and creator screen under the Works tab, a credits editor in the work's draft, a creator filter, and a creator-roles section in Settings. | | [Place Extraction](#place-extraction) | 2026-09-09 | Done | Full spec (T-2276). Places as a second record kind beside characters: the same model request returns both, one review list decides both with a per-row kind switch, a kept record converts between kinds in the editor, and places rank, cite entries and sync as characters do. `Place` and `PlaceSuppression` under schema V13 with markers `"12"` → `"13"` and the archive at 12/13; the character store code becomes generic over a record row. | | [Work Thumbnails](#work-thumbnails) | 2026-09-11 | Done — all 40 tasks implemented; `make test-core`, `make test-quick`, `make build-mac` and `make build-ios` green with no new warnings, `WorkCoverUITests` green and the `WorkDetailCreditsUITests` case re-run green (`verification-run.md` §2, §3 and §4). The store-shape probe passed on the phone on 2026-09-12 (§1). Open: `make test-performance-m4` has no quiet-host band — the runs on this branch were made on a machine at load 3–412 and disagree with each other by 1.7× — the owner's device steps in `prerequisites.md` (the two-install sync check, the Mac checklist, the `PasteButton` check), and Q80's decision on the export's peak, which the review's archive work cut from ~464 MB to ~418 MB but did not bring under 400 MB | Full spec (T-2330). A reader-chosen cover image per work: bytes in an external-storage column beside a shape and a digest under schema V14 with markers `"13"` → `"14"` and the archive at 13/14; set from the work editor by Find cover — head sources, a page-body `<img>` arm with a Scan page action, and a query-less sibling for every candidate URL — or Photos, file or paste, cropped to portrait or square with the zoom floor at the fit scale and the gap stored out, saved with the work, shown in 56×84 row slots and a 160×240 header with a long-press viewer, carried by the archive. No on-device model ranks candidates. |+| [Entry to Work Link](#entry-to-work-link) | 2026-09-14 | Done — all 5 tasks implemented 2026-09-14 to 2026-09-16; `make test-quick` green with no new warnings; every phone UI suite green on the branch across three partial `make test-ui` runs plus per-suite runs (the M4Scale trio excepted); `make test-ui-ipad` ran all 25 cases twice with the new case green both times and one different runner-crash failure each time, no single exit 0 (`verification-run.md`) | Smolspec. The work name on the entry detail screen becomes a control that opens the work's detail screen. One navigation-owned route: from a chapter on top of the Works path it drops the chapter and keeps what is beneath the work; from any other tab it shows that one work with nothing beneath it. Plain text stays for an unresolved or blank name and inside the Merge sheet. App-layer only; no Core, schema or repository change. | --- @@ -723,3 +724,17 @@ Full spec (T-2330). A **thumbnail** is one optional cover image per work, reader - [prerequisites.md](work-thumbnails/prerequisites.md) - [explanation.md](work-thumbnails/explanation.md) - [verification-run.md](work-thumbnails/verification-run.md)++---++## Entry to Work Link++**Created:** 2026-09-14 · **Status:** Done — smolspec and five tasks approved 2026-09-14; model, navigation and screen implemented the same day with `make test-quick` green and the phase review's fixes folded in (Q7); the four iPhone journeys and the iPad journey landed 2026-09-15 and were verified 2026-09-16 across a host that killed or refused several runs (`verification-run.md`)++Smolspec. The entry detail screen's title card names the work a note belongs to, and that name becomes the control that opens the work's detail screen, with a trailing chevron and the serif face kept (Q5). The name is a link only when the detail read resolved the work under a non-blank title (Q3); the candidate-title fallback, a work not in the library, and the Merge sheet's copy of the screen keep plain text (Q4). One navigation-owned route, `AppNavigation.openWork(_:fromEntry:)` (Q2): when the reader is in the Works tab and the entry is the chapter on top of the Works path, the chapter is dropped and whatever sits beneath the work stays, so Back still returns to the series or creator screen it was opened through; from any other tab the Works tab is selected showing that one work with nothing beneath it, whatever the Works path held (Q7). The control stays enabled on a read-only entry (Q6). App-layer only: `EntrySnapshot.workID` and `EntryTeachingDetail.workDisplayTitle` already carried what it needs.++- [smolspec.md](entry-to-work-link/smolspec.md)+- [tasks.md](entry-to-work-link/tasks.md)+- [decision_log.md](entry-to-work-link/decision_log.md)+- [verification-run.md](entry-to-work-link/verification-run.md)+- [implementation.md](entry-to-work-link/implementation.md)
diff --git a/specs/entry-to-work-link/decision_log.md b/specs/entry-to-work-link/decision_log.mdnew file mode 100644index 0000000..a7cca15--- /dev/null+++ b/specs/entry-to-work-link/decision_log.md@@ -0,0 +1,14 @@+# Decision Log: Entry to Work Link++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-09-14 | Smolspec rather than full spec | No schema, Core or repository change; the snapshot already carries `workID` and the detail read already resolves the title. Every choice reverts with the branch. |+| Q2 | 2026-09-14 | One navigation-owned route, `openWork(_:fromEntry:)`: `pushWork` when the entry is the chapter on top of the Works path, `showWork` otherwise; never a push of work detail into the host stack | Work detail's own routes (chapters, series, creators, merge) are all driven by `worksPath`, so a work screen pushed into Recent's stack would have dead rows or need a second path. `showWork` is the route every out-of-tab caller already takes (Stats, Check Library) and is right from Recent. From a chapter it is wrong: it replaces the whole path, so a chapter reached through a series or creator screen would lose that screen and Back would land on the Works list. `pushWork` already drops the trailing chapter and refuses to duplicate the work on top (its own guard, pinned by `AppNavigationTests.pushWorkOnTheSameWorkOnlyDropsTheChapter`; the `showWork`/`pushWork` split itself is `series-and-related-works` Q49), which is exactly the in-tab outcome. Q7 narrows the condition to the Works tab. The two are one method so the rule is unit-tested, not spread over hosts. |+| Q3 | 2026-09-14 | The name is a link only when the detail read resolved the work (`workDisplayTitle` non-nil and non-blank) | A `workID` naming a work not in the library is a tolerated state, not damage; a link to it would open nothing. The candidate-title fallback names a parse, not a row. A blank name would render as a bare chevron, and blank rather than empty is the test `LibraryRepository` already refuses a work title on (`createWork`, `updateWork`), so a whitespace-only name is one no writer could have meant. |+| Q4 | 2026-09-14 | The Merge sheet's entry detail keeps the name as plain text, through a per-host `opensWork` flag on `EntryDetailRoute` | The sheet stays presented over a tab switch, so the route would be invisible, and the work it opens is the one already under the sheet. This is the first per-host switch on the route wrapper; `showsSky` is a layout fact, not a host-capability one, so it cannot be derived from that. No `pendingRoute`: that mechanism exists for Settings, which dismisses first, and the merge sheet has no reason to. |+| Q5 | 2026-09-14 | The title text itself is the control, serif face kept, trailing chevron added; identifier `entry-detail-work-title` stays on it | A separate "Open work" row would be a second thing to scan on a card that is about the note; the name is what the reader would tap. The chevron is the only affordance change, and `anyElement` queries survive the type change. The control is the series row and `linkRow` pattern in `WorkDetailView`, with the `Spacer` outside the button so the captured-page link keeps its hit area. |+| Q6 | 2026-09-14 | The link stays enabled on a read-only entry and while an update is in flight | Opening a work writes nothing, and the work page is where a divergent Work set's own resolve pill lives; disabling the way there on a torn entry would trap the reader. The rating buttons keep their disabled state because they are writes. |+| Q7 | 2026-09-14 | `openWork` pushes only when the reader is in the Works tab and the entry is the chapter on top; from any other tab it replaces, whatever the Works path holds | The Works tab can be parked on that same chapter while the reader opens the entry from Recent — the same entry is reachable from both places, and the Works stack is not reset by leaving it. Keying the push on the path alone would then push onto a stack the reader never came in by, and Requirement 2's out-of-tab clause promises that reader the work with nothing beneath it. The tab is what says which stack the reader is actually in, so it is half of the condition. |+| Q8 | 2026-09-16 | A blank resolved work name counts as no resolved name: the card falls back to the candidate title, as plain text, and one private `resolvedWorkTitle` on the model owns that rule for both the drawn name and the link | With the rule only on `linkedWorkID`, a blank `workDisplayTitle` left `titleCardWorkContext` returning the blank string, so the card drew an empty name carrying the identifier, skipped a candidate title that was usable, and offered no link — the two properties disagreed on one input. Reading both from one property makes disagreement impossible by construction. Found in the pre-push review; the state is reachable only by sync or import from another build, since the repository refuses a blank title locally. |
diff --git a/specs/entry-to-work-link/smolspec.md b/specs/entry-to-work-link/smolspec.mdnew file mode 100644index 0000000..551fd57--- /dev/null+++ b/specs/entry-to-work-link/smolspec.md@@ -0,0 +1,47 @@+# Entry to Work Link++## Overview++The entry detail screen names the work its note belongs to in the title card, but that name is plain text: a reader on a note has no way to the work it is a chapter of except Back (when the note was opened from the work) or the Works tab. This change makes the work name in the title card the control that opens the work's detail screen, so a note reached from Recent, or from anywhere else, is one tap from the whole work. No Core, schema or repository change: the entry snapshot already carries `workID` and the detail read already resolves the work's display title.++## Requirements++- WHEN the title card names a resolved work (the entry has a `workID` and the detail read found the work), the system MUST render that name as a control that opens the work's detail screen in the Works tab, on iPhone, iPad and Mac.+- WHEN the reader is in the Works tab and the entry is the chapter on top of the Works path, the system MUST leave the chapter and land on its work with everything beneath the work still in place, so Back from the work returns to the series, creator or origin work it was opened through (`series-and-related-works` Decision 7, whose rationale is that Back always returns to the previous screen). WHEN the entry was moved to another work while open as a chapter, the new work is opened on top of the old one, so Back returns to the work the reader came through. In every other case — including a Works tab parked on that same chapter while the entry is opened from another tab (Q7) — the system MUST behave as the existing out-of-tab routes do (Stats breakdown rows, Check Library rows): the Works tab is selected showing that one work with nothing beneath it. Recent's own stack is left as it was; the Works list's unattached-note selection lives on the Works stack and is cleared with it, though that route never shows the link (Requirement 3).+- WHEN the title card shows the unresolved candidate title instead (no `workID`, or the referenced work is not in the library), the system MUST render it as plain text, as today. A blank resolved name (empty or whitespace-only) counts as no resolved name: the card falls back to the candidate title, as plain text, rather than drawing a blank name (Q8). An absent work is tolerated, never an error. An intentionally unattached entry always has no `workID` (the flag is set in the same write that clears the work), so the Works list's unattached-note route never shows the link.+- Inside the Merge sheet's entry detail, the system MUST render the work name as plain text: a tab switch under a presented sheet is invisible, and the work it would open is the one the sheet is already showing.+- The control MUST stay enabled while the entry is read-only (a torn group) and while an update is in flight: opening a work writes nothing. The rating buttons keep their existing disabled state.+- The control MUST keep the identifier `entry-detail-work-title`, keep the serif heading face and its wrapping (no line limit is added), carry an accessibility label of the form `Open Work {title}` (the Works list's convention, `WorksView.swift:757`) with the chevron hidden from accessibility so the title is read once, and show a trailing chevron so it reads as tappable.+- The captured-page link MUST keep its own hit area: the button's hit shape is its own label, and the `Spacer` between the name and the link glyph stays outside the button.+- The rating buttons and every other part of the screen MUST be unchanged.+- At `UICTContentSizeCategoryAccessibilityXXXL` on iPhone the control MUST be hittable and MUST open the work.++## Implementation Approach++- **`Asterism/Asterism/ViewModels/EntryDetailModel.swift`**: beside `titleCardWorkContext` (line 213), add `linkedWorkID: UUID?`: `entry?.workID` only when the resolved work title is non-nil and non-blank (whitespace-trimmed, the test `LibraryRepository` refuses a work title on), otherwise nil. One private `resolvedWorkTitle` owns the blankness rule and both `titleCardWorkContext` and `linkedWorkID` read it, so the card cannot draw a name it will not link (Q8). The two Core fields are `EntrySnapshot.workID` (`Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift:27`) and `EntryTeachingDetail.workDisplayTitle` (`Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift:228`), resolved in `LibraryRepository+EntryDetail.swift:221–232`.+- **`Asterism/Asterism/Layout/AppNavigation.swift`**: add `openWork(_ workID: UUID, fromEntry entryID: UUID)` beside `showWork` (line 299) and `pushWork` (line 312): when `selectedTab == .works && selectedWorkChapterEntryID == entryID` (the file's own accessor for the chapter on top of the path) call `pushWork(workID)`, which drops the trailing chapter and does not append a work already on top — its own guard, pinned by `AppNavigationTests.pushWorkOnTheSameWorkOnlyDropsTheChapter` (after a Move To from that chapter, `[.work(old), .chapter]` becomes `[.work(old), .work(new)]`); otherwise call `showWork(workID)`. No new state and no new `WorksRoute` case.+- **`Asterism/Asterism/Views/EntryDetailView.swift`**: add an optional `onOpenWork: ((UUID) -> Void)?` beside `onResolveDuplicate` (line 35), nil by default as that one is. In `titleSection` (lines 327–396), when `model.linkedWorkID` and the callback are both present, wrap the existing `Text` (one `workNameText` helper serves both arms) in a plain-style `Button` whose label is the text plus a `chevron.right` marked `accessibilityHidden`, with `contentShape(Rectangle())` on the label and `frame(minHeight: AsterismLayout.minHitTarget)`; otherwise keep the `Text`. The identifier and the serif face move onto the control. The chevron pattern is `WorkDetailView.swift:1264–1290` (series row) and `1904–1930` (`linkRow`), except that here the `Spacer` stays outside the button.+- **`Asterism/Asterism/Layout/EntryDetailRoute.swift`**: pass `{ navigation.openWork($0, fromEntry: entryID) }` as `onOpenWork`, behind a new `opensWork: Bool = true` parameter so the one host that must not route can say so. The six stack hosts (`CompactRootView.swift:112, 146, 156`; `WideRootView.swift:149, 227, 250`) take the default.+- **`Asterism/Asterism/ContentView.swift:575`**: the Merge sheet's `EntryDetailRoute` passes `opensWork: false`.+- Pattern: the Check Library row's Open work action (`specs/wrong-host-work-url-heal/design.md:101`, `AppNavigation.takePendingRoute`) is the existing "open a work from another screen" route and resolves to the same `showWork`. `showWork`'s replace-not-append contract is `AppNavigation.swift:283–303` and `AppNavigationTests.swift:93`; `pushWork`'s drop-the-chapter contract is `AppNavigation.swift:305–317`.+- Dependencies: `EntrySnapshot.workID`, `EntryTeachingDetail.workDisplayTitle`, `AppNavigation.showWork` and `pushWork`.+- Out of scope: pushing work detail into Recent's own stack; a work route from inside the Merge sheet; a Mac keyboard command for it (`ipad-and-mac-layouts` Req 6.1 lists the command set, as `works-list-options` Q10 left it); any change to what the title card names; `pendingRoute`; a Mac hover effect.++## Risks and Assumptions++- Risk: the compact Recent stack keeps the entry pushed after the tab switch, so returning to Recent shows the note, not the list. | Mitigation: that is the existing behaviour of every cross-tab route (Stats, Check Library) and is the right one; the UI test asserts the Works side only.+- Risk: turning the `Text` into a `Button` changes how existing UI tests find `entry-detail-work-title`. | Mitigation: `WorkDetailActionsUITests.swift:235` queries it type-agnostically through `anyElement`; the new tests do the same.+- Risk: in the wide tree a chapter under its work already has a `ColumnBackButton` to the work, so the screen carries two controls with one destination. | Mitigation: accepted; the link is the same control on every host and a chapter is the one place it is redundant rather than wrong.+- Assumption: from the Works list's unattached-note route, `showWork` clearing `selectedWorksEntryID` and setting the path in one turn pops the note and pushes the work; that route never shows the link today (no `workID`), so this is reachable only if the unattached rule changes.+- Assumption: in the wide tree, `showWork` selects the Works pane and the detail column renders `worksPath.last`, as `WideLayoutUITests.testAChapterReplacesItsWorkInTheDetailColumnAndComesBack` (line 374) relies on today.++## Test Plan++- Unit (`Asterism/AsterismTests/EntryDetailModelTests.swift`): `linkedWorkID` is present when the detail resolves a work title, and stays present on a read-only entry; absent when the title is the candidate fallback, when it is blank (empty or whitespace-only), and when `workID` is nil.+- Unit (`Asterism/AsterismTests/AppNavigationTests.swift`): `openWork(_:fromEntry:)` from `[.series, .work(A), .chapter(e)]` leaves `[.series, .work(A)]`; from `[.work(A), .chapter(e)]` leaves `[.work(A)]`; from `[.work(A), .chapter(e)]` opening B leaves `[.work(A), .work(B)]`; from Recent (`selectedTab == .recent`, any Works path) selects Works with `[.work(A)]`; from a Works path whose top is not that entry's chapter it replaces the path.+- UI, iPhone (`make test-ui`): in the `seeded-composed` scenario (the one `WorkDetailActionsUITests` asserts `entry-detail-work-title` on; the `seeded-m1` Integration Chapter row has no `workID` and names no work), open a Recent row of the composed-taught `id.test` chapters, whose work `Real Work` exists in the fixture; tap the work name and assert the Works tab shows `work-detail-title` for that work. From a chapter under a work (`WorkDetailActionsUITests.openWorkDetail` then a `work-detail-entry` row), tap it and assert the work is on screen with the chapter gone. At accessibility XXXL the control is hittable and opens the work. The Merge sheet's entry shows the name as non-interactive text.+- UI, iPad (`make test-ui-ipad`): from the Recent pane's detail column, the name switches the window to the Works pane with the work in the detail column.+- `make test-quick` green, no new warnings. The Mac has no UI-test target; its coverage is the shared wide tree.++## 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/entry-to-work-link/tasks.md b/specs/entry-to-work-link/tasks.mdnew file mode 100644index 0000000..1a65e9c--- /dev/null+++ b/specs/entry-to-work-link/tasks.md@@ -0,0 +1,50 @@+---+references:+ - specs/entry-to-work-link/smolspec.md+ - specs/entry-to-work-link/decision_log.md+---+# Entry to Work Link++## Model and navigation++- [x] 1. The entry detail model tells the view which work the title card can open <!-- id:r0bsyig -->+ - Per specs/entry-to-work-link/smolspec.md (Requirements 1 and 3, Implementation Approach bullet 1, decision_log Q3 and Q6).+ - EntryDetailModel exposes linkedWorkID beside titleCardWorkContext: the entry's workID only when the loaded detail carries a non-empty workDisplayTitle; otherwise nil.+ - Success: unit tests in Asterism/AsterismTests/EntryDetailModelTests.swift show it present when a work title resolves and still present on a read-only entry; absent for the candidate-title fallback; absent for an empty title; absent for a nil workID. make test-quick green.+ - Stream: 1++- [x] 2. Navigation owns the entry-to-work route and keeps the stack beneath a chapter work <!-- id:r0bsyih -->+ - Per specs/entry-to-work-link/smolspec.md (Requirement 2, Implementation Approach bullet 2, decision_log Q2).+ - AppNavigation gains openWork(_:fromEntry:) beside showWork and pushWork: pushWork when the Works path's top is that entry's chapter; showWork otherwise. No new state and no new WorksRoute case.+ - Success: unit tests in Asterism/AsterismTests/AppNavigationTests.swift cover the five cases in the smolspec Test Plan: series beneath the work survives; a bare work and chapter becomes the work alone; a moved chapter opens the new work on top of the old; from Recent the Works tab shows only that work; a Works path whose top is not that entry's chapter is replaced. make test-quick green.+ - Stream: 2++## Screen++- [x] 3. The title card work name opens the work on every host except the Merge sheet <!-- id:r0bsyii -->+ - Per specs/entry-to-work-link/smolspec.md (Requirements 1 and 4 to 8, Implementation Approach bullets 3 to 5, decision_log Q4 and Q5).+ - EntryDetailView takes an optional onOpenWork callback that is nil by default. When linkedWorkID and the callback are both present the title card renders the work name as a plain-style button on the series-row pattern in WorkDetailView: serif face kept; trailing chevron hidden from accessibility; contentShape on the label; minimum hit height; the Spacer stays outside the button so entry-detail-link keeps its hit area; identifier entry-detail-work-title kept; accessibility label Open Work followed by the title. Otherwise the existing Text stays.+ - The button is not disabled by isReadOnly or by an in-flight update; the rating buttons keep their disabled state.+ - EntryDetailRoute passes navigation.openWork behind an opensWork parameter defaulting to true; the Merge sheet route in ContentView passes false; the six stack hosts take the default.+ - Success: make test-quick green with no new warnings; WorkDetailActionsUITests.testAChapterRowOpensItsEntry still finds the identifier.+ - Blocked-by: r0bsyig (The entry detail model tells the view which work the title card can open), r0bsyih (Navigation owns the entry-to-work route and keeps the stack beneath a chapter work)+ - Stream: 1++## Journeys++- [x] 4. iPhone journeys reach the work from Recent and from a chapter at default and accessibility sizes <!-- id:r0bsyij -->+ - Per specs/entry-to-work-link/smolspec.md (Test Plan, iPhone bullet; docs/agent-notes/testing.md on UI reachability through real navigation).+ - From Recent in the seeded-composed scenario the journey opens a composed-taught id.test chapter row and taps the work name; the Works tab shows work-detail-title for Real Work. The seeded-m1 Integration Chapter row has no workID and cannot carry the link.+ - From a work's chapter row the journey taps the name; the work is on screen and the chapter is gone.+ - At UICTContentSizeCategoryAccessibilityXXXL the control is hittable and opens the work.+ - In the Merge sheet the entry's work name is not a button.+ - Success: the new cases and the existing suites pass under make test-ui.+ - Blocked-by: r0bsyii (The title card work name opens the work on every host except the Merge sheet)+ - Stream: 1++- [x] 5. The iPad detail column reaches the work from the Recent pane <!-- id:r0bsyik -->+ - Per specs/entry-to-work-link/smolspec.md (Test Plan, iPad bullet; Risks and Assumptions, wide-tree assumption).+ - A WideLayoutUITests case beside testAChapterReplacesItsWorkInTheDetailColumnAndComesBack selects an entry in the Recent pane and taps the work name in the detail column; the window is on the Works pane with that work in the detail column.+ - Success: the new case and the existing suites pass under make test-ui-ipad.+ - Blocked-by: r0bsyii (The title card work name opens the work on every host except the Merge sheet)+ - Stream: 2
diff --git a/specs/entry-to-work-link/verification-run.md b/specs/entry-to-work-link/verification-run.mdnew file mode 100644index 0000000..bb530be--- /dev/null+++ b/specs/entry-to-work-link/verification-run.md@@ -0,0 +1,74 @@+# Verification Run: Entry to Work Link++Branch `worktree-entry-to-work-link`, runs made 2026-09-15 and 2026-09-16 on the+owner's Mac Studio. The host was not cooperative: three full `make test-ui` runs+were ended from outside (one by the screen lock, one by this session's own+mistake, one by an unidentified SIGTERM), the simulator refused runner launches+on a boot race, and a Time Machine backup pushed the one-minute load to 63 for a+quarter of an hour. What follows is what the branch has actually proven.++## 1. Unit bar++`make test-quick` exit 0 with no new warnings on every commit from the model+(e15a739) through the review fixes (9fa4c04). It ran under the screen lock+without trouble; only the UI runner is affected by the lock.++## 2. iPhone, `make test-ui`++No single run exited 0, and none of the three was allowed to finish:++| Run | Started | Ended | Reached | New cases |+|-----|---------|-------|---------|-----------|+| 1 | 2026-09-15 23:06 | 01:40, cascade after the screen locked at ~00:06 | through `SeriesUITests` | 4/4 green |+| 2 | 2026-09-16 08:10 | 08:43, SIGTERM at the second this session backgrounded a `caffeinate` | through `ComposedSurfaceUITests` | 4/4 green |+| 3 | 2026-09-16 08:53 | 10:15, SIGTERM of unknown origin | through `WorkDetailCreditsUITests` | 4/4 green |++The seven suites past that point were then run one at a time through+`make test-only TEST=AsterismUITests/<Suite>` between 13:03 and 13:58:+`WorkDetailCreditsUITests`, `WorkDetailStatusUITests`,+`WorksAndAssignmentUITests`, `WorksCreatorOptionsUITests`,+`WorksListOptionsUITests`, `WorksSeriesOptionsUITests` and+`WorkTypesSettingsUITests`, each exit 0. Together the three runs and the seven+single-suite runs cover all 30 phone suites.++Failures seen, none of them this feature's:++- `M4ScaleRecentPerformanceUITests`' three seed-timeout cases, the documented+ trio (`docs/agent-notes/testing.md`), in every run that reached them.+- `AccessibilityJourneyUITests.testThePlacesSectionAndReviewKindControlAtLargestDynamicType`+ and `…testTheStatusControlsAndDialogStayOperableAtLargestDynamicType`, once+ each in run 3; both passed in runs 1 and 2, and neither touches the entry+ title card or the navigation route.+- Everything after the lock in run 1 failed at its first "the library opens"+ wait, which is the lock, not the code.++The four new cases — the three in `EntryToWorkLinkUITests` and+`AccessibilityJourneyUITests.testTheEntryTitleCardsWorkNameOpensTheWorkAtLargestDynamicType`+— passed in all three runs.++## 3. iPad, `make test-ui-ipad`++Four attempts. The runner on this host loses one case per full run to a crash,+as `specs/work-thumbnails/verification-run.md` §2.5 also recorded.++| Attempt | Result |+|---------|--------|+| 1, 07:58 | 12 passed, then the runner died mid-suite; the new case never ran |+| 2, 08:46 | **25 executed, 24 passed**; `testThePlacesSectionAndReviewKindControlInTheDetailColumn` lost to a runner crash (no assertion line, immediate suite restart); the new case green |+| 3, 13:44 | **25 executed, 24 passed**; `testTheDetailColumnStartsOnAPlaceholderAndTakesTheSelectedRow` lost the same way; the new case green |+| 4, 14:05 | the runner never worked: every case refused at the 42 s launch rhythm — *not run*, per the testing note |++Every one of the 25 cases passed in attempt 2 or 3, and 23 of them in both. The+new case, `WideLayoutUITests.testTheWorkNameInTheDetailColumnOpensTheWorkOnTheWorksPane`,+passed both times. No attempt exited 0; task 5 is marked complete on that+evidence and this paragraph is the caveat.++## 4. What the host taught++Recorded in the session memory and worth a line here: the "Busy / failed+preflight checks" refusal is a boot race — with a warm build, `xcodebuild`+asks a device it has just booted to launch the runner about 30 s in, before+SpringBoard is up. Booting the exact device with `simctl boot` and waiting on+`simctl bootstatus -b` before `make` cleared six consecutive refusals of+`WorkTypesSettingsUITests` on the first try. A Time Machine run or Spotlight+indexing fresh build products on top of that makes it worse.
Two full runs of 25 cases, the new case green both times, one different unrelated case lost to a runner crash each time. Decide whether that is the bar for this branch or whether a clean run is still owed before the push.
minHeight on the button inside a firstTextBaseline HStack may change the card's height by a few points. Req 8 says the rest of the screen is unchanged and nothing tests that; a glance on the phone settles it.
Reachable only by sync or import from another build, but titleCardWorkContext now returns the candidate title where it returned the blank string before.
The one added UI assertion, the Open Work Real Work label on the accessibility journey, was proven with a single boot-first run of that case.