asterism scope 8 commits since 643109e unpushed total 48 commits files 30 touched lines +2286 / -862 tests 622 core + full UI suite green findings 6 fixed, 3 ticketed

Pre-push review #2: teaching surface redesign

Second review pass, scoped to the 8 commits since the first review (30 files, +2,286 / −862): the Decision 8 teaching-surface redesign plus the first review's own fixes. The earlier 40 commits were reviewed and remediated in round 1 and were deliberately not re-reviewed.

At a glance

  • Fixed — regression from the first review: the URL split editor destroyed its own state on every edit, because seedIfNeeded could not distinguish an external definition change from one the editor caused. All local dispatches now record what they published.
  • Fixed — a requirement I wrote was factually wrong. Req 8.6 claimed the prevented class was "two adjacent part spans". Those cannot occur. The real class is a whitespace-only phrase separator, which means Some Story 12 cannot source its chapter from the title at all — an undocumented limitation, now stated.
  • Fixed — two false claims still in the changelog: the kept-span selector and character-level boundary controls were described as shipped in the same unreleased block that says they were removed.
  • Ticketed, not decided: URL details auto-expand on every first teach, which undercuts Decision 1's collapsed default (T-1912); .chapterlessPhrase is valid but unauthorable, contradicting Req 1.2 (T-1913); a skipped title role still says nothing (T-1911).
  • Verified clean: the inference table is total over reachable selections, the scalar→character offset mapping degrades safely on combining marks and emoji, and the re-teach invariant survives subdivide, refusal, and the Articles enter/cancel cycle.

Verdict

Ready to push

One functional regression found, and it was mine — the first review's fix for the URL editor silently narrowing a retained rule introduced a worse bug in its place. Seeding the editor's chip selections from the retained definition was correct, but no dispatch site recorded what it published, so the editor could not tell an external change from its own. Every local edit round-tripped and re-seeded, which closed the within-component split editor on the first token tap and, in the failure path, made a retained URL rule vanish with no message.

It shipped because the split editor had no UI coverage at all. It now has a test, verified to fail without the fix and pass with it. The remaining findings were documentation that had drifted out of true and two design questions I ticketed rather than settled unilaterally.

Review findings

9 raised · 6 fixed · 3 skipped

Jump to findings →

Commits

Important changes — detailed

URL split editor: record what the editor itself published

Asterism/Asterism/Views/ComposedURLDetailsEditor.swift

Why it matters. A functional regression introduced by the first review's own fix, invisible to every existing test. The within-component split — the feature that exists for URLs like /Story-28614-94/ — was unusable.

What to look at. ComposedURLDetailsEditor.publish(_:), routing all 12 dispatch sites

Takeaway. Mirroring owned state into a child view is only half the job: the child must also be able to recognise the echo of its own writes, or every edit reads as an external change and clobbers the edit in progress. A single seededFrom update at the publish point is the whole fix.
Rationale. The alternative — comparing old and new values inside onChange — cannot work here, because a local edit and an external change can produce the same definition.

Req 8.6 described a class of illegal selection that cannot occur

specs/unified-teaching-composition/requirements.md

Why it matters. The criterion I wrote said the surface prevents "two adjacent part spans". Parts are maximal alphanumeric runs, so no two are ever adjacent. The real constraint is much broader and was documented nowhere.

What to look at. requirements.md Req 8.6

Takeaway. A phrase separator must contain a non-whitespace scalar, so any two parts separated only by spaces cannot become Work and chapter. Titles shaped like `Some Story 12` therefore cannot source a chapter from the title at all — their chapter has to come from a URL sequence.
Rationale. Verified against M2Unicode.isBlank (whitespace-only, not empty) and PhrasePatternDeriver's blankSeparator guard, then confirmed by constructing the failing case.

Regression test proved, not assumed

Asterism/AsterismUITests/ComposedSurfaceUITests.swift

Why it matters. The split editor had zero UI coverage, which is exactly why the regression shipped. A test that passes on fixed code proves nothing on its own.

What to look at. testWithinComponentSplitSurvivesTokenEdits

Takeaway. Reverting the fix and watching the new test fail is cheap and is the only thing that distinguishes a regression test from a test that happens to agree with current behaviour.
Rationale. Confirmed failing without the fix ("The split editor opens and stays open after being invoked") and passing with it.

Key decisions

Scope: the earlier 40 commits were not re-reviewed.

48 commits are unpushed, but 40 were reviewed and remediated in the first pass earlier in the same session. Re-reviewing fixed work would have spent the effort where the risk had already been removed, so the agents were scoped to the 8 unreviewed commits. Stated here rather than narrowed silently.

Two design questions ticketed rather than settled.

The auto-expand tension with Decision 1, and .chapterlessPhrase being valid-but-unauthorable, are both judgement calls with real alternatives. Deciding either inside a review pass would bury a design decision in a bug-fix commit.

clearURLSelection deleted rather than kept for its test.

It lost its only production caller when the duplicate clear button was removed, and was identical to setURLRuleDefinition(nil) apart from a no-op guard. Its test now drives the path production actually takes, which is better coverage than the alias had.

Review findings

SeverityAreaFindingResolution
majorComposedURLDetailsEditor split stateseedIfNeeded could not distinguish an external definition change from one the editor caused, because no dispatch site updated seededFrom. Every local edit round-tripped and re-seeded: the split editor closed on the first token tap, and a failed template derivation cleared its own error message before it could render, so a retained URL rule vanished silently.All local dispatches routed through publish(_:), which records the seed before calling onRuleChange. Regression test added and verified to fail without the fix.
majorReq 8.6 wordingThe criterion claimed the prevented class was 'two adjacent part spans, which yield the blank separator .phrase rejects'. Parts are maximal alphanumeric runs, so two of them are never adjacent. The actual class — any two parts separated only by whitespace — is far broader and means common titles like `Some Story 12` cannot source a chapter from the title.Req 8.6 rewritten to state the real constraint and name the URL sequence as the remedy; the same wrong explanation removed from the agent notes. T-1911 filed for surfacing the reason to the reader.
minorCHANGELOGTwo bullets still described removed UI as shipped: the kept-span title selector with character-level boundary controls, and boundary controls in the URL authoring surface. Both sat in the same unreleased block as the entry stating they were removed.Both claims removed. This is the third round of the same class of defect on this branch — unreleased entries describing superseded behaviour.
minorspecs/.../implementation.mdWritten before the redesign landed: still framed the branch as '40 commits, 142 files', and listed phrase-mode restoration on re-teach as a known gap when the redesign's phraseSpans now implements it.Header corrected, the closed gap removed, and an addendum added covering Decision 8 and the whitespace-separator limitation.
minorComposedTeachingViewModelclearURLSelection() lost its only production caller when the duplicate clear button was removed, leaving it reachable only from a test. It was identical to setURLRuleDefinition(nil) apart from a no-op guard.Deleted; the test retargeted at the path the editor actually takes.
minordocs/agent-notes/composed-teaching-ui.mdReferred to the renamed SegmentChipView, repeated the wrong adjacent-part-spans explanation, and carried a 'deviation from the requirement wording' section arguing against text that Q30 has since amended to agree with it.All three corrected; the deviation section reframed as rationale pointing at Q30.
minorSkipped roles are silent (T-1911)cycleTitleRole skips unauthorable roles, which satisfies 'prevent rather than report'. But when one role is skipped and another applied, nothing is said — the same silent swallow that was fixed for the no-candidate case. The existing notice copy also asserts the Work-name cause for a branch reachable by several.Not fixed — needs inference to return a typed reason rather than a bare nil, and a copy decision. Filed as T-1911, high priority, with the shape sketched.
minorAuto-expand vs Decision 1 (T-1912)The default selection sources no chapter, so the URL details auto-expand on every first teach of an untaught Site. 'Collapsed by default' is unreachable in the common case, which undercuts Decision 1's rationale that title-only teaching stays minimal. Two requirements now pull against each other with nothing recording which wins.Not fixed — a design question with three viable answers. Filed as T-1912 rather than decided inside a review pass.
minor.chapterlessPhrase unauthorable (T-1913)The closed tuple set admits a chapter-less phrase rule, but nothing can author one: the inference table maps subdivided-parts-Work-only to whole-title plus trims, migration cannot produce it, and no backup carries it. Req 1.2 forbids valid-but-unreachable states, and Req 8.6 now asserts the table covers the closed set.Not fixed — either the design's tuple-set wording changes or the arm is deleted, and deleting touches the V4 wire format. Filed as T-1913.

Per-file diffs

Click to expand.

Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +6 / -4
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 491891b..a74e40e 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -240,9 +240,11 @@ public final class AppLibraryModel {     /// Provides a detail model for a specific work.     public func workDetailModel(for id: UUID) -> WorkDetailModel? {         guard let repo = repository else { return nil }-        return WorkDetailModel(workID: id, library: repo, onMutation: { [weak self] in-            await self?.refreshAll()-        })+        return WorkDetailModel(+            workID: id, library: repo, capabilities: capabilities,+            onMutation: { [weak self] in+                await self?.refreshAll()+            })     }      /// Provides a model for creating a new work.@@ -277,7 +279,7 @@ public final class AppLibraryModel {         return ComposedTeachingViewModel(             entry: row.entry,             library: repo,-            capabilities: .m4,+            capabilities: capabilities,             entryContext: context,             onMutation: { [weak self] in                 await self?.refreshAll()
Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift Modified +471 / -303
diff --git a/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift b/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swiftindex 4c597f3..2537a2d 100644--- a/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift+++ b/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift@@ -8,13 +8,21 @@ import OSLog /// through `commitComposedTeaching`. /// /// Responsibilities:-/// - Title selection: a kept-span whole-title selector (Req 3.3, the new primary-///   interaction) plus the retained segment/phrase/articles editor modes.+/// - Title selection at two granularities (Req 3.3, 8.3, Decision 8): the example+///   title's delimiter-split segments, each cycling Work → chapter → ignore, and+///   parts of a segment the reader subdivides in place. The rule *form* is+///   inferred from the selection and never chosen (Req 8.6); selections that+///   cannot author a legal rule are unreachable rather than reported. /// - A progressive-disclosure URL section (Req 8.2, Decision 1): collapsed by-///   default; auto-expanded when the Site already holds a URL rule or teaching is-///   entered from a URL-focused context; collapsing is presentation-only and-///   retains the selection with a summary and an explicit clear (Q11).+///   default; auto-expanded when the Site already holds a URL rule, when teaching+///   is entered from a URL-focused context, or when the title selection leaves the+///   chapter unsourced (the disclosure is then the remedy). Collapsing is+///   presentation-only and retains the selection with a summary and an explicit+///   clear (Q11). /// - Per-commit unsettled-chapters acknowledgment (Req 2.1, Q3), never persisted.+/// - The Articles affordance (Req 8.7): a one-way Site transition kept out of+///   title selection, projected and committed through `projectArticles` /+///   `commitArticles`, never the composed commit. /// - Generation-gated preview publication carried from `URLTeachingViewModel` ///   (Req 8.5): only the latest edit generation may publish a preview. @MainActor @Observable@@ -43,15 +51,6 @@ public final class ComposedTeachingViewModel {         case cancelled     } -    /// The title interaction in use. `.wholeTitle` is the milestone's new primary-    /// interaction and the minimum title selection (Decision 5).-    public enum TitleMode: Equatable, Sendable {-        case wholeTitle-        case segments-        case phrase-        case articles-    }-     /// The URL details disclosure (Req 8.2). Its collapsed/expanded state is     /// conveyed by the stock `DisclosureGroup` chevron (a non-color indicator).     public enum DisclosureState: Equatable, Sendable {@@ -66,13 +65,9 @@ public final class ComposedTeachingViewModel {         case urlFocused     } -    public enum PhraseBoundary: Equatable, Sendable { case start, end }-    public enum PhraseBoundaryDirection: Equatable, Sendable { case backward, forward }-     // MARK: - Published state      public private(set) var state: State = .loading-    public private(set) var titleMode: TitleMode = .wholeTitle     public private(set) var disclosureState: DisclosureState = .collapsed     public private(set) var frozenBasis: ComposedTeachingBasis?     public private(set) var previewOutcome: ComposedTeachingOutcome?@@ -88,21 +83,24 @@ public final class ComposedTeachingViewModel {     /// The generation at which the current preview was computed.     public private(set) var previewGeneration: Int = -1 -    // Title — whole-title (kept-span) mode-    /// The character range of the example title that is kept as the Work name.-    /// Whole title → `.wholeTitle` with no trims; a narrower span authors trims.-    public private(set) var keptSpan: Range<Int> = 0..<0--    // Title — segment mode-    public private(set) var segments: [String] = []-    public private(set) var roles: [SegmentRole] = []-    public private(set) var validationError: TeachingValidationError?--    // Title — phrase mode-    public private(set) var phraseSelection = PhraseSelection(chapter: 0..<0, work: 0..<0)-    public private(set) var phraseValidationMessage: String?--    // Title — articles mode+    // Title selection (Req 3.3, 8.3, 8.6)+    /// The example title's delimiter-split segments.+    public private(set) var titleSegments: [ComposedTeachingPresentation.TitleSegment] = []+    /// The tappable chip row: whole segments, with subdivided segments replaced+    /// in place by their parts.+    public private(set) var titleChips: [ComposedTeachingPresentation.TitleChip] = []+    /// One role per chip, in chip order.+    public private(set) var titleRoles: [SegmentRole] = []+    /// Segment indices currently shown as parts.+    public private(set) var subdividedSegments: Set<Int> = []+    /// A short explanation for a chip tap the selection could not apply (Req 8.4).+    /// The tap is still refused rather than accepted and then reported (Req 8.6);+    /// this only replaces the silence. Cleared by the next selection change.+    public private(set) var titleSelectionNotice: String?++    // Articles (Req 8.7) — separate from title selection+    /// Whether the reader has opened the Articles affordance's confirmation.+    public private(set) var articlesRequested: Bool = false     public private(set) var junkSuffixSegmentCount = 0     public private(set) var articleValidationMessage: String? @@ -124,6 +122,22 @@ public final class ComposedTeachingViewModel {     private var previewTask: Task<Void, Never>?     private var isSubmitting = false +    /// The title rule the Site already holds, captured at load. Until the reader+    /// edits the title selection, the commit reuses this definition verbatim, so+    /// re-teaching only the URL rule leaves the title rule untouched — the+    /// canonicalizing comparator then declines to re-version it (Req 1.3, Q5).+    private var retainedTitleRule: ComposedTitleRuleBasis?+    /// Whether the reader has edited the title selection during this session.+    private var titleEdited = false+    /// Whether the disclosure has already auto-expanded for an unsourced chapter,+    /// so a reader who collapses it again is not fought by the same trigger.+    private var didAutoExpandForChapter = false++    /// Articles mode is a one-way Site transition, not a composed title rule, so+    /// it keeps its own contract and commits through `commitArticles` rather than+    /// `commitComposedTeaching` (which requires an active title rule).+    private var articlesContract: ArticlesContract?+     // MARK: - Derived, presentation-facing      public var exampleTitle: String { entry.captureTitle }@@ -133,46 +147,59 @@ public final class ComposedTeachingViewModel {     /// The backing library, exposed so a container can drive the post-commit     /// Work landing-URL queue without a second dependency handle.     public var provider: any LibraryProviding { library }-    public var supportsPhraseTeaching: Bool { capabilities.supportsPhraseTeaching }     public var supportsArticles: Bool { capabilities.supportsArticles } -    /// The alphanumeric-run token ranges of the example title — the tappable-    /// tokens of the kept-span selector.-    public var titleTokenRanges: [Range<Int>] { Self.tokenRanges(in: entry.captureTitle) }+    /// Whether any segment is currently shown as its parts.+    public var isSubdivided: Bool { !subdividedSegments.isEmpty } -    /// The Work name that the current title selection produces, previewed live-    /// (Req 3.3). Whole-title mode shows the trimmed span; structured modes show-    /// the resolved Work name from the last preview when available.-    public var workNamePreview: String {-        switch titleMode {-        case .wholeTitle:-            let characters = Array(entry.captureTitle)-            let clamped = clampedKeptSpan(characterCount: characters.count)-            return clamped.isEmpty ? entry.captureTitle : String(characters[clamped])-        case .segments, .phrase, .articles:-            return previewOutcome?.entries.first(where: { $0.entryID == entry.id })?.workName-                ?? entry.captureTitle+    /// The rule the surface would commit right now: the Site's retained rule+    /// until the reader edits the title, then the rule inferred from the chips.+    public var effectiveTitleRule: ComposedTeachingPresentation.InferredTitleRule? {+        if !titleEdited, let retained = retainedTitleRule {+            return ComposedTeachingPresentation.InferredTitleRule(+                definition: retained.definition,+                trimPrefix: retained.trimPrefix, trimSuffix: retained.trimSuffix)         }+        return selectedTitleRule     } -    /// The exact leading trim the current kept span authors (nil when none).-    public var trimPrefix: String? {-        guard titleMode == .wholeTitle else { return nil }-        let characters = Array(entry.captureTitle)-        let clamped = clampedKeptSpan(characterCount: characters.count)-        let prefix = String(characters[0..<clamped.lowerBound])-        return prefix.isEmpty ? nil : prefix+    /// The rule the current chip selection authors, ignoring the retained rule.+    private var selectedTitleRule: ComposedTeachingPresentation.InferredTitleRule? {+        guard let rule = ComposedTeachingPresentation.inferredTitleRule(+            title: exampleTitle, segments: titleSegments, chips: titleChips, roles: titleRoles),+              (try? capabilities.validate(patternDefinition: rule.definition)) != nil+        else { return nil }+        return rule     } -    /// The exact trailing trim the current kept span authors (nil when none).-    public var trimSuffix: String? {-        guard titleMode == .wholeTitle else { return nil }-        let characters = Array(entry.captureTitle)-        let clamped = clampedKeptSpan(characterCount: characters.count)-        let suffix = String(characters[clamped.upperBound..<characters.count])-        return suffix.isEmpty ? nil : suffix+    /// The effective rule applied to the example title — the live preview of what+    /// the current selection names (Req 3.3).+    private var titlePreview: TitleRuleParseResult? {+        guard let rule = effectiveTitleRule,+              case .success(let parsed) = TitleRuleApplicator.apply(+                definition: rule.definition, trimPrefix: rule.trimPrefix,+                trimSuffix: rule.trimSuffix, to: entry.captureTitle)+        else { return nil }+        return parsed     } +    /// The Work name the current selection produces, previewed live (Req 3.3).+    public var workNamePreview: String {+        titlePreview?.workName+            ?? previewOutcome?.entries.first(where: { $0.entryID == entry.id })?.workName+            ?? entry.captureTitle+    }++    /// The chapter title the current selection produces, or nil when the title+    /// sources no chapter.+    public var chapterNamePreview: String? { titlePreview?.chapterTitle }++    /// The exact leading trim the current selection authors (nil when none).+    public var trimPrefix: String? { effectiveTitleRule?.trimPrefix }++    /// The exact trailing trim the current selection authors (nil when none).+    public var trimSuffix: String? { effectiveTitleRule?.trimSuffix }+     /// Whether the current rule set would leave chapters with no source and thus     /// needs the per-commit acknowledgment (Req 2.1).     public var requiresUnsettledAcknowledgment: Bool {@@ -186,27 +213,33 @@ public final class ComposedTeachingViewModel {         return Self.summarize(urlRuleDefinition)     } -    /// Whether the current title selection leaves a field unsourced, so the-    /// disclosure additionally hints that the title is missing details (Req 8.2).-    public var missingDetailsHint: Bool {-        // The whole-title selection with no URL rule leaves chapters unsettled.-        titleProducesChapter == false && (urlRuleDefinition?.suppliesSequence ?? false) == false-    }--    /// Whether the current title selection produces a chapter title.-    private var titleProducesChapter: Bool {-        switch titleMode {-        case .wholeTitle, .articles: false-        case .segments: roles.contains(.chapter) && roles.contains(.work)-        case .phrase: true-        }+    /// Whether the composed rule set currently sources no chapter at all. The URL+    /// details auto-expand and present themselves as the remedy in this state+    /// (Req 8.2 as amended by Decision 8); it mirrors the projection's own+    /// unsettled-chapters gate.+    public var chapterUnsourced: Bool {+        let titleChapter = effectiveTitleRule?.definition.producesChapter ?? false+        let urlChapter = urlRuleDefinition?.suppliesSequence ?? false+        return !titleChapter && !urlChapter     }      public var canConfirm: Bool {-        guard state == .previewReady, previewGeneration == generation else { return false }+        guard !articlesRequested, state == .previewReady, previewGeneration == generation else { return false }         return contract != nil     } +    public var canConfirmArticles: Bool {+        articlesRequested && state == .previewReady && previewGeneration == generation+            && articlesContract != nil+    }++    /// The articles preview: how many Entries the one-way transition would+    /// detach and rename. Nil until the affordance's projection lands.+    public var articlesPreviewEntryCount: Int? {+        guard articlesRequested else { return nil }+        return articlesContract?.outcome.plan.entryProjections.count+    }+     // MARK: - Init      public init(@@ -227,26 +260,20 @@ public final class ComposedTeachingViewModel {      public func load() async {         state = .loading-        let characters = Array(entry.captureTitle)-        keptSpan = 0..<characters.count--        // Tokenize for segment mode.-        switch DelimiterTokenizer.tokenize(entry.captureTitle) {-        case .success(let tokenized):-            segments = tokenized.segments-            roles = Array(repeating: .chapter, count: tokenized.segments.count)-        case .failure:-            segments = [entry.captureTitle]-            roles = [.chapter]-        }+        titleSegments = ComposedTeachingPresentation.titleSegments(in: entry.captureTitle)+        resetSelectionToWholeTitle()          // Load the frozen basis via an initial whole-title projection, so we can-        // read the Site's current URL rule and auto-expand the disclosure.+        // read the Site's current rules and seed the editors from them.         do {             let initial = try await projectContract(                 titleDefinition: .wholeTitle, trimPrefix: nil, trimSuffix: nil,                 urlDefinition: nil, acknowledge: false)             frozenBasis = initial.basis+            retainedTitleRule = initial.basis.currentTitleRule+            if let currentTitle = initial.basis.currentTitleRule {+                seedTitleEditor(from: currentTitle)+            }             if let currentURL = initial.basis.currentURLRule {                 urlRuleDefinition = currentURL.definition                 disclosureState = .expanded@@ -258,7 +285,7 @@ public final class ComposedTeachingViewModel {             contract = nil             previewOutcome = nil             state = .ready-            revalidate()+            syncChapterRemedyDisclosure()             await generatePreviewIfValid()         } catch {             errorMessage = "Unable to load teaching basis. \(error.localizedDescription)"@@ -273,163 +300,165 @@ public final class ComposedTeachingViewModel {      /// Collapsing is presentation-only: the URL selection stays in the preview and     /// the commit (Q11). A summary is shown while collapsed.-    public func collapseDisclosure() { disclosureState = .collapsed }--    public func toggleDisclosure() {-        disclosureState = disclosureState == .expanded ? .collapsed : .expanded+    public func collapseDisclosure() {+        disclosureState = .collapsed+        // A deliberate collapse wins over the chapter-remedy trigger until the+        // selection leaves and re-enters the unsourced state.+        didAutoExpandForChapter = chapterUnsourced     } -    /// Explicitly clears the retained URL selection (the only way to remove it-    /// once made, Q11).-    public func clearURLSelection() {-        guard urlRuleDefinition != nil else { return }-        urlRuleDefinition = nil-        invalidatePreview()-        Task { await generatePreviewIfValid() }-    }--    // MARK: - Title mode--    public func selectTitleMode(_ mode: TitleMode) {-        guard titleMode != mode else { return }-        if mode == .phrase, !capabilities.supportsPhraseTeaching {-            phraseValidationMessage = AsterismCapabilityError.unavailablePatternForm(-                form: .phrase, gate: capabilities.gate).description-            return+    public func toggleDisclosure() {+        disclosureState == .expanded ? collapseDisclosure() : expandDisclosure()+    }++    // MARK: - Title chip selection (Req 3.3, 8.3, 8.6)++    /// Cycles a chip's role Work → chapter → ignore. Roles that would leave a+    /// selection unable to author a legal rule are skipped, so an illegal+    /// selection is unreachable rather than reported after the fact (Req 8.6).+    /// A role that only stays legal by demoting *other* chips yields to a later+    /// role in the cycle that does not, so passing through the cycle never+    /// silently discards a selection the reader already made.+    public func cycleTitleRole(at index: Int) {+        guard titleRoles.indices.contains(index) else { return }+        let order: [SegmentRole] = [.work, .chapter, .ignore]+        guard let position = order.firstIndex(of: titleRoles[index]) else { return }+        var candidates: [(roles: [SegmentRole], collateral: Bool)] = []+        for step in 1..<order.count {+            var naive = titleRoles+            naive[index] = order[(position + step) % order.count]+            let normalized = ComposedTeachingPresentation.normalizedRoles(naive, changedIndex: index)+            guard isAuthorable(normalized) else { continue }+            candidates.append((normalized, normalized != naive))         }-        if mode == .articles, !capabilities.supportsArticles {-            articleValidationMessage = AsterismCapabilityError.articlesUnavailable(gate: capabilities.gate).description+        guard let chosen = candidates.first(where: { !$0.collateral }) ?? candidates.first else {+            // Nothing this chip could become authors a rule — in practice it is+            // the last chip naming the Work, which a taught site always needs+            // (Req 1.1, Decision 5). Refuse the tap, but say why (Req 8.4).+            titleSelectionNotice = ComposedTeachingPresentation.lastWorkNotice             return         }-        titleMode = mode-        phraseValidationMessage = nil-        articleValidationMessage = nil-        if mode == .phrase {-            phraseSelection = phraseSelectionFromSegmentRoles() ?? defaultPhraseSelection()+        titleRoles = chosen.roles+        commitTitleEdit()+    }++    /// Subdivides an already-selected segment in place into its parts, which+    /// become individually selectable in the same chip row (Req 8.3). Parts+    /// inherit the segment's role; if that inheritance cannot author a rule the+    /// selection falls back to the whole title rather than a dead end.+    public func subdivideSegment(atChip index: Int) {+        guard titleChips.indices.contains(index) else { return }+        let chip = titleChips[index]+        guard chip.canSubdivide, !chip.isPart, !subdividedSegments.contains(chip.segmentIndex) else { return }++        // Existing chips keep their own role; the newly split segment's parts+        // inherit the role the whole segment held.+        var roleByRange: [Range<Int>: SegmentRole] = [:]+        var roleBySegment: [Int: SegmentRole] = [:]+        for (chipIndex, existing) in titleChips.enumerated() {+            roleByRange[existing.range] = titleRoles[chipIndex]+            if !existing.isPart { roleBySegment[existing.segmentIndex] = titleRoles[chipIndex] }         }-        invalidatePreview()-        revalidate()-        Task { await generatePreviewIfValid() }-    }--    // MARK: - Kept-span selection (whole-title mode)--    /// Toggle a title token into or out of the kept span. Selecting an interior-    /// token collapses the span to it; an outside token extends; an edge token is-    /// cut off. The span never becomes empty.-    public func toggleTitleToken(at index: Int) {-        let tokens = titleTokenRanges-        let characters = Array(entry.captureTitle)-        keptSpan = Self.adjustedSpan(-            togglingTokenAt: index, tokens: tokens, current: clampedKeptSpan(characterCount: characters.count))-        invalidatePreview()-        Task { await generatePreviewIfValid() }-    }--    /// Move the kept-span start boundary by one character (the M3 char-level-    /// boundary control retained for sub-token precision).-    public func adjustKeptSpanStart(by delta: Int) {-        let count = entry.captureTitle.count-        let current = clampedKeptSpan(characterCount: count)-        let start = min(max(0, current.lowerBound + delta), current.upperBound - 1)-        keptSpan = start..<current.upperBound-        invalidatePreview()-        Task { await generatePreviewIfValid() }-    }--    public func adjustKeptSpanEnd(by delta: Int) {-        let count = entry.captureTitle.count-        let current = clampedKeptSpan(characterCount: count)-        let end = max(min(count, current.upperBound + delta), current.lowerBound + 1)-        keptSpan = current.lowerBound..<end-        invalidatePreview()-        Task { await generatePreviewIfValid() }-    }--    public func resetKeptSpanToWholeTitle() {-        keptSpan = 0..<entry.captureTitle.count-        invalidatePreview()-        Task { await generatePreviewIfValid() }+        var subdivided = subdividedSegments+        subdivided.insert(chip.segmentIndex)+        let chips = ComposedTeachingPresentation.titleChips(segments: titleSegments, subdividing: subdivided)+        var roles = chips.map { roleByRange[$0.range] ?? roleBySegment[$0.segmentIndex] ?? .work }+        if !isAuthorable(roles, chips: chips) {+            // Demoting the chapter usually recovers; the whole title always does.+            roles = roles.map { $0 == .chapter ? .ignore : $0 }+            if !isAuthorable(roles, chips: chips) {+                roles = [SegmentRole](repeating: .work, count: chips.count)+            }+        }+        subdividedSegments = subdivided+        titleChips = chips+        titleRoles = roles+        commitTitleEdit()+    }++    /// Returns the whole chip row to segment granularity — the default and the+    /// more robust form (Req 8.6).+    public func useWholeSegments() {+        guard isSubdivided else { return }+        // A segment takes the strongest role any of its parts held.+        func rank(_ role: SegmentRole) -> Int {+            switch role {+            case .work: 2+            case .chapter: 1+            case .ignore: 0+            }+        }+        var segmentRoles: [Int: SegmentRole] = [:]+        for (index, chip) in titleChips.enumerated() {+            let role = titleRoles[index]+            if rank(role) > rank(segmentRoles[chip.segmentIndex] ?? .ignore) || segmentRoles[chip.segmentIndex] == nil {+                segmentRoles[chip.segmentIndex] = role+            }+        }+        let chips = ComposedTeachingPresentation.titleChips(segments: titleSegments, subdividing: [])+        var roles = chips.map { segmentRoles[$0.segmentIndex] ?? .work }+        if !isAuthorable(roles, chips: chips) {+            roles = [SegmentRole](repeating: .work, count: chips.count)+        }+        subdividedSegments = []+        titleChips = chips+        titleRoles = roles+        commitTitleEdit()     } -    // MARK: - Segment role editing+    // MARK: - Articles affordance (Req 8.7) -    public func cycleRole(at index: Int) {-        guard titleMode == .segments, roles.indices.contains(index) else { return }-        switch roles[index] {-        case .chapter: roles[index] = .work-        case .work: roles[index] = .ignore-        case .ignore: roles[index] = .chapter-        }+    /// Opens the Articles confirmation. Articles is a one-way Site transition,+    /// not a title rule form, so it never enters the composed request.+    public func requestArticles() {+        guard supportsArticles, !articlesRequested else { return }+        articlesRequested = true+        junkSuffixSegmentCount = 0+        articleValidationMessage = nil+        titleSelectionNotice = nil+        previewTask?.cancel()         invalidatePreview()-        revalidate()-        Task { await generatePreviewIfValid() }+        Task { await projectArticlesAndPublish() }     } -    // MARK: - Phrase editing--    public func setPhraseSelection(_ role: PhraseFieldRole, range: Range<Int>) {-        guard titleMode == .phrase else { return }-        let count = exampleTitle.count-        guard range.lowerBound >= 0, range.upperBound <= count else {-            phraseValidationMessage = PhraseTeachingError.selectionOutOfBounds(role: role).localizedDescription-            invalidatePreview()-            return-        }-        switch role {-        case .chapter: phraseSelection = PhraseSelection(chapter: range, work: phraseSelection.work)-        case .work: phraseSelection = PhraseSelection(chapter: phraseSelection.chapter, work: range)-        }+    public func cancelArticles() {+        guard articlesRequested else { return }+        articlesRequested = false+        articlesContract = nil+        articleValidationMessage = nil         invalidatePreview()-        revalidatePhrase()         Task { await generatePreviewIfValid() }     } -    public func adjustPhraseBoundary(-        _ role: PhraseFieldRole, boundary: PhraseBoundary, direction: PhraseBoundaryDirection-    ) {-        guard titleMode == .phrase else { return }-        let current = phraseSelection.range(for: role)-        let delta = direction == .forward ? 1 : -1-        let adjusted: Range<Int>-        switch boundary {-        case .start:-            let start = min(max(0, current.lowerBound + delta), current.upperBound)-            adjusted = start..<current.upperBound-        case .end:-            let end = min(max(current.lowerBound, current.upperBound + delta), exampleTitle.count)-            adjusted = current.lowerBound..<end-        }-        setPhraseSelection(role, range: adjusted)-    }--    public func selectedText(for role: PhraseFieldRole) -> String {-        PhrasePatternDeriver.selectedText(in: exampleTitle, selection: phraseSelection, role: role) ?? ""-    }--    // MARK: - Articles editing-     public func setJunkSuffixSegmentCount(_ count: Int) {-        guard titleMode == .articles else { return }+        guard articlesRequested else { return }         junkSuffixSegmentCount = count         invalidatePreview()         do {             _ = try ArticleTitleCleaner.deriveRule(from: entry.captureTitle, suffixSegmentCount: count)             articleValidationMessage = nil-            Task { await generatePreviewIfValid() }+            Task { await projectArticlesAndPublish() }         } catch {             articleValidationMessage = error.localizedDescription         }     } +    public func confirmArticles() async {+        guard canConfirmArticles else { return }+        await commitArticlesContract()+    }+     // MARK: - URL rule editing (absorbed)      /// Sets the current URL rule definition (or nil to clear) and re-previews. The     /// view derives the definition from chip selections; the model holds it and     /// composes it into the single preview.+    /// Sets the URL rule definition, or clears it with nil — the editor's+    /// explicit clear is the only way to remove a made selection (Q11).     public func setURLRuleDefinition(_ definition: URLRuleDefinition?) {         urlRuleDefinition = definition         invalidatePreview()+        syncChapterRemedyDisclosure()         Task { await generatePreviewIfValid() }     } @@ -458,9 +487,14 @@ public final class ComposedTeachingViewModel {     }      public func reconfirm() async {-        guard requiresReconfirmation, state == .refreshed,-              previewGeneration == generation, contract != nil else { return }-        await commitCurrentContract()+        guard requiresReconfirmation, state == .refreshed, previewGeneration == generation else { return }+        if articlesRequested {+            guard articlesContract != nil else { return }+            await commitArticlesContract()+        } else {+            guard contract != nil else { return }+            await commitCurrentContract()+        }     }      public func retry() async {@@ -483,6 +517,10 @@ public final class ComposedTeachingViewModel {     /// the latest when the projection returns.     private func generatePreviewIfValid() async {         previewTask?.cancel()+        if articlesRequested {+            await projectArticlesAndPublish()+            return+        }         guard let request = buildRequest() else {             invalidateContractOnly()             return@@ -515,6 +553,10 @@ public final class ComposedTeachingViewModel {     /// Awaitable projection used by the acknowledgment and retry paths, which must     /// have a fresh contract in hand before proceeding.     private func projectAndPublish() async {+        if articlesRequested {+            await projectArticlesAndPublish()+            return+        }         guard let request = buildRequest() else {             invalidateContractOnly()             return@@ -532,6 +574,43 @@ public final class ComposedTeachingViewModel {         }     } +    /// Project the one-way articles transition. Articles carries no title rule,+    /// so it never builds a `ComposedTeachingRequest`.+    private func projectArticlesAndPublish() async {+        guard articleValidationMessage == nil else {+            invalidateContractOnly()+            return+        }+        let rule: JunkSuffixRule?+        if junkSuffixSegmentCount > 0 {+            guard let derived = try? ArticleTitleCleaner.deriveRule(+                from: entry.captureTitle, suffixSegmentCount: junkSuffixSegmentCount) else {+                invalidateContractOnly()+                return+            }+            rule = derived+        } else {+            rule = nil+        }++        let currentGeneration = generation+        state = .previewing+        do {+            let projected = try await library.projectArticles(hostname: entry.hostname, junkSuffixRule: rule)+            guard currentGeneration == generation else { return }+            articlesContract = projected+            contract = nil+            previewOutcome = nil+            previewGeneration = currentGeneration+            state = .previewReady+        } catch {+            guard currentGeneration == generation else { return }+            errorMessage = "Unable to generate preview. Library unchanged."+            state = .error+            Self.logger.error("Articles preview failed: \(String(describing: error), privacy: .public)")+        }+    }+     private func publish(_ projected: ComposedTeachingContract, generation: Int) {         contract = projected         previewOutcome = projected.outcome@@ -558,6 +637,38 @@ public final class ComposedTeachingViewModel {         }     } +    private func commitArticlesContract() async {+        guard !isSubmitting, let articlesContract else { return }+        isSubmitting = true+        defer { isSubmitting = false }+        state = .confirming+        errorMessage = nil++        do {+            switch try await library.commitArticles(articlesContract) {+            case .committed:+                committedOutcome = nil+                requiresReconfirmation = false+                state = .committed+                Task { await onMutation?() }+                Self.logger.debug("Articles mode committed for \(self.hostname, privacy: .public)")+            case .refreshed(let fresh):+                self.articlesContract = fresh+                previewGeneration = generation+                requiresReconfirmation = true+                state = .refreshed+            case .invalidated(let reason):+                invalidationReason = reason+                requiresReconfirmation = false+                state = .invalidated+            }+        } catch {+            errorMessage = "Unable to save articles mode. Library unchanged."+            state = .error+            Self.logger.error("Articles commit failed: \(String(describing: error), privacy: .public)")+        }+    }+     private func handleCommitOutcome(_ outcome: ComposedTeachingCommitOutcome) {         switch outcome {         case .committed:@@ -589,7 +700,7 @@ public final class ComposedTeachingViewModel {     /// Builds the composed request from the current selections, or nil if the     /// title selection is not yet a valid rule.     private func buildRequest() -> ComposedTeachingRequest? {-        guard let title = buildTitleDefinition() else { return nil }+        guard let title = effectiveTitleRule else { return nil }         return ComposedTeachingRequest(             titleDefinition: title.definition,             trimPrefix: title.trimPrefix,@@ -598,29 +709,6 @@ public final class ComposedTeachingViewModel {             acknowledgeUnsettled: acknowledgedUnsettled)     } -    private func buildTitleDefinition() -> (definition: PatternDefinition, trimPrefix: String?, trimSuffix: String?)? {-        switch titleMode {-        case .wholeTitle:-            return (.wholeTitle, trimPrefix, trimSuffix)-        case .segments:-            guard validationError == nil, segments.count >= 2 else { return nil }-            let assignment = SegmentRoleAssignment(roles: roles)-            guard case .success(let validated) = TeachingValidator.validate(assignment: assignment, segments: segments),-                  let definition = PatternDeriver.deriveSegmentPattern(from: validated) else { return nil }-            return (definition, nil, nil)-        case .phrase:-            guard phraseValidationMessage == nil,-                  let definition = try? PhrasePatternDeriver.derive(from: exampleTitle, selection: phraseSelection)-            else { return nil }-            return (definition, nil, nil)-        case .articles:-            // Articles is authored through the dedicated whole-title chapterless-            // form (Work-name = trimmed title) is not applicable; articles retains-            // its own path and is not composed-committed here.-            return nil-        }-    }-     // MARK: - Projection      private func projectContract(@@ -633,28 +721,6 @@ public final class ComposedTeachingViewModel {         return try await library.projectComposedTeaching(hostname: entry.hostname, request: request)     } -    // MARK: - Validation--    private func revalidate() {-        guard titleMode == .segments else { validationError = nil; return }-        guard segments.count >= 2 else { validationError = .noWorkSegment; return }-        let assignment = SegmentRoleAssignment(roles: roles)-        switch TeachingValidator.validate(assignment: assignment, segments: segments) {-        case .success: validationError = nil-        case .failure(let error): validationError = error-        }-    }--    private func revalidatePhrase() {-        do {-            let definition = try PhrasePatternDeriver.derive(from: exampleTitle, selection: phraseSelection)-            try capabilities.validate(patternDefinition: definition)-            phraseValidationMessage = nil-        } catch {-            phraseValidationMessage = error.localizedDescription-        }-    }-     // MARK: - Invalidation      private func invalidatePreview() {@@ -663,6 +729,7 @@ public final class ComposedTeachingViewModel {         defer { Self.performanceSignposter.endInterval("ComposedEditAcknowledgement", signpostState) }         generation += 1         contract = nil+        articlesContract = nil         previewOutcome = nil         requiresReconfirmation = false         if state == .previewReady || state == .previewing || state == .requiresAcknowledgment {@@ -674,59 +741,160 @@ public final class ComposedTeachingViewModel {     /// counter — used when the selection is not yet a valid rule.     private func invalidateContractOnly() {         contract = nil+        articlesContract = nil         previewOutcome = nil         if state == .previewReady || state == .previewing { state = .ready }     } -    // MARK: - Helpers+    // MARK: - Selection helpers -    private func clampedKeptSpan(characterCount: Int) -> Range<Int> {-        let lower = min(max(0, keptSpan.lowerBound), max(0, characterCount - 1))-        let upper = min(max(lower + 1, keptSpan.upperBound), characterCount)-        return lower..<upper+    private func commitTitleEdit() {+        titleEdited = true+        titleSelectionNotice = nil+        invalidatePreview()+        syncChapterRemedyDisclosure()+        Task { await generatePreviewIfValid() }     } -    private func phraseSelectionFromSegmentRoles() -> PhraseSelection? {-        let characterCount = exampleTitle.count-        guard characterCount >= 2 else { return nil }-        return nil+    private func resetSelectionToWholeTitle() {+        titleSelectionNotice = nil+        subdividedSegments = []+        titleChips = ComposedTeachingPresentation.titleChips(segments: titleSegments, subdividing: [])+        titleRoles = [SegmentRole](repeating: .work, count: titleChips.count)     } -    private func defaultPhraseSelection() -> PhraseSelection {-        let characterCount = exampleTitle.count-        return characterCount >= 2-            ? PhraseSelection(chapter: 0..<1, work: (characterCount - 1)..<characterCount)-            : PhraseSelection(chapter: 0..<0, work: 0..<0)+    private func isAuthorable(+        _ roles: [SegmentRole], chips: [ComposedTeachingPresentation.TitleChip]? = nil+    ) -> Bool {+        guard let rule = ComposedTeachingPresentation.inferredTitleRule(+            title: exampleTitle, segments: titleSegments, chips: chips ?? titleChips, roles: roles)+        else { return false }+        return (try? capabilities.validate(patternDefinition: rule.definition)) != nil     } -    /// The alphanumeric-run token ranges of a title (character offsets).-    static func tokenRanges(in text: String) -> [Range<Int>] {-        var ranges: [Range<Int>] = []-        var runStart: Int?-        for (offset, character) in text.enumerated() {-            if character.isLetter || character.isNumber {-                if runStart == nil { runStart = offset }-            } else if let start = runStart {-                ranges.append(start..<offset)-                runStart = nil+    /// Expands the URL details as the chapter remedy the first time the composed+    /// selection leaves the chapter unsourced (Req 8.2 as amended).+    private func syncChapterRemedyDisclosure() {+        guard chapterUnsourced else {+            didAutoExpandForChapter = false+            return+        }+        guard !didAutoExpandForChapter else { return }+        didAutoExpandForChapter = true+        disclosureState = .expanded+    }++    /// Seed the title selector from the rule the Site already holds, so+    /// re-teaching opens on what is in effect rather than on a fresh whole-title+    /// selection. Segment forms invert their anchors onto chip roles; whole-title+    /// trims and phrase literals are located back onto character spans. In every+    /// case `retainedTitleRule` keeps the commit faithful until the reader+    /// actually edits the title.+    private func seedTitleEditor(from rule: ComposedTitleRuleBasis) {+        switch rule.definition {+        case .wholeTitle:+            let span = keptSpan(forTrimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix)+            applySelection(workSpan: span, chapterSpan: nil)+        case .segment(let work, let ignored):+            applySegmentSelection(work: work, ignored: ignored, chapterFromRemainder: true)+        case .chapterlessSegment(let work, let ignored):+            applySegmentSelection(work: work, ignored: ignored, chapterFromRemainder: false)+        case .phrase, .chapterlessPhrase:+            if let spans = phraseSpans(for: rule) {+                applySelection(workSpan: spans.work, chapterSpan: spans.chapter)             }         }-        if let start = runStart { ranges.append(start..<text.count) }-        return ranges-    }--    /// The span that results from toggling one token while a contiguous span is-    /// selected. Never becomes empty.-    static func adjustedSpan(togglingTokenAt index: Int, tokens: [Range<Int>], current: Range<Int>) -> Range<Int> {-        guard tokens.indices.contains(index) else { return current }-        let selected = tokens.indices.filter { tokens[$0].overlaps(current) }-        guard let first = selected.first, let last = selected.last else { return tokens[index] }-        if index < first { return tokens[index].lowerBound..<tokens[last].upperBound }-        if index > last { return tokens[first].lowerBound..<tokens[index].upperBound }-        if first == last { return current }-        if index == first { return tokens[first + 1].lowerBound..<tokens[last].upperBound }-        if index == last { return tokens[first].lowerBound..<tokens[last - 1].upperBound }-        return tokens[index]+    }++    private func applySelection(workSpan: Range<Int>, chapterSpan: Range<Int>?) {+        let selection = ComposedTeachingPresentation.titleSelection(+            in: exampleTitle, workSpan: workSpan, chapterSpan: chapterSpan)+        let chips = ComposedTeachingPresentation.titleChips(+            segments: titleSegments, subdividing: selection.subdivided)+        guard chips.count == selection.roles.count, selection.roles.contains(.work) else { return }+        subdividedSegments = selection.subdivided+        titleChips = chips+        titleRoles = selection.roles+    }++    private func applySegmentSelection(+        work: SegmentRangeSpec, ignored: [SegmentPositionSpec], chapterFromRemainder: Bool+    ) {+        guard let restored = Self.roles(+            work: work, ignored: ignored, segmentCount: titleSegments.count,+            remainder: chapterFromRemainder ? .chapter : .ignore) else { return }+        subdividedSegments = []+        titleChips = ComposedTeachingPresentation.titleChips(segments: titleSegments, subdividing: [])+        titleRoles = restored+    }++    /// The character spans a phrase rule selected in this example title, when the+    /// rule still parses it. `.phrase` is `prefix + FIELD + separator + FIELD ++    /// suffix`, so the parsed field lengths locate both spans exactly; a title the+    /// rule no longer parses leaves the selection at its default.+    private func phraseSpans(for rule: ComposedTitleRuleBasis) -> (work: Range<Int>, chapter: Range<Int>)? {+        guard case .phrase(let prefix, _, let suffix, let order) = rule.definition,+              case .success(let parsed) = TitleRuleApplicator.apply(+                definition: rule.definition, trimPrefix: rule.trimPrefix,+                trimSuffix: rule.trimSuffix, to: entry.captureTitle),+              let chapterTitle = parsed.chapterTitle,+              rule.trimPrefix == nil, rule.trimSuffix == nil else { return nil }++        let characters = Array(entry.captureTitle)+        let count = characters.count+        let firstLength = order == .chapterThenWork ? chapterTitle.count : parsed.workName.count+        let secondLength = order == .chapterThenWork ? parsed.workName.count : chapterTitle.count+        let firstSpan = prefix.count..<(prefix.count + firstLength)+        let secondSpan = (count - suffix.count - secondLength)..<(count - suffix.count)+        guard firstSpan.lowerBound >= 0, firstSpan.upperBound <= count,+              secondSpan.lowerBound >= firstSpan.upperBound, secondSpan.upperBound <= count else { return nil }+        let firstText = String(characters[firstSpan])+        let secondText = String(characters[secondSpan])+        let expectedFirst = order == .chapterThenWork ? chapterTitle : parsed.workName+        let expectedSecond = order == .chapterThenWork ? parsed.workName : chapterTitle+        guard firstText == expectedFirst, secondText == expectedSecond else { return nil }+        return order == .chapterThenWork+            ? (work: secondSpan, chapter: firstSpan)+            : (work: firstSpan, chapter: secondSpan)+    }++    /// The kept span that reproduces the retained whole-title trims. Trims are+    /// exact affixes and may not appear in this particular example title; when+    /// they do not, the span falls open to the whole title, matching the+    /// applicator's fail-open behavior (Req 3.2).+    private func keptSpan(forTrimPrefix prefix: String?, trimSuffix suffix: String?) -> Range<Int> {+        let characters = Array(entry.captureTitle)+        var lower = 0+        var upper = characters.count+        if let prefix, !prefix.isEmpty, entry.captureTitle.hasPrefix(prefix) {+            lower = prefix.count+        }+        if let suffix, !suffix.isEmpty, entry.captureTitle.hasSuffix(suffix) {+            upper = characters.count - suffix.count+        }+        guard lower < upper else { return 0..<characters.count }+        return lower..<upper+    }++    /// Invert `AnchorDerivation` — resolve a stored Work range and ignored+    /// positions back onto per-segment roles. Returns nil when the anchors do not+    /// resolve within this title's segment count.+    private static func roles(+        work: SegmentRangeSpec, ignored: [SegmentPositionSpec], segmentCount: Int, remainder: SegmentRole+    ) -> [SegmentRole]? {+        guard segmentCount > 0 else { return nil }+        let workLower = work.origin == .start ? work.offset : segmentCount - work.offset - work.length+        let workUpper = workLower + work.length+        guard workLower >= 0, workUpper <= segmentCount else { return nil }++        var restored = [SegmentRole](repeating: remainder, count: segmentCount)+        for index in workLower..<workUpper { restored[index] = .work }+        for position in ignored {+            let index = position.origin == .start ? position.offset : segmentCount - 1 - position.offset+            guard restored.indices.contains(index), restored[index] != .work else { continue }+            restored[index] = .ignore+        }+        return restored     }      /// A short reader-facing summary of a URL rule definition (collapsed-state
Asterism/Asterism/ViewModels/EntryDetailModel.swift Modified +1 / -1
diff --git a/Asterism/Asterism/ViewModels/EntryDetailModel.swift b/Asterism/Asterism/ViewModels/EntryDetailModel.swiftindex 620cd38..ea45b88 100644--- a/Asterism/Asterism/ViewModels/EntryDetailModel.swift+++ b/Asterism/Asterism/ViewModels/EntryDetailModel.swift@@ -110,7 +110,7 @@ public final class EntryDetailModel {         return ComposedTeachingViewModel(             entry: entry,             library: library,-            capabilities: .m4,+            capabilities: capabilities,             entryContext: context,             onMutation: { [weak self] in                 await self?.load()
Asterism/Asterism/ViewModels/WorkDetailModel.swift Modified +9 / -2
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex 51fafe2..ebecdae 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -33,12 +33,19 @@ public final class WorkDetailModel {      private let workID: UUID     private let library: any LibraryProviding+    private let capabilities: AsterismCapabilities     private let onMutation: @Sendable () async -> Void     private var isSubmitting = false -    public init(workID: UUID, library: any LibraryProviding, onMutation: @escaping @Sendable () async -> Void) {+    public init(+        workID: UUID,+        library: any LibraryProviding,+        capabilities: AsterismCapabilities = .current,+        onMutation: @escaping @Sendable () async -> Void+    ) {         self.workID = workID         self.library = library+        self.capabilities = capabilities         self.onMutation = onMutation     } @@ -117,7 +124,7 @@ public final class WorkDetailModel {         return ComposedTeachingViewModel(             entry: example,             library: library,-            capabilities: .m4,+            capabilities: capabilities,             entryContext: .urlFocused,             onMutation: { [weak self] in                 await self?.onMutation()
Asterism/Asterism/Views/ComposedTeachingPresentation.swift Modified +240 / -3
diff --git a/Asterism/Asterism/Views/ComposedTeachingPresentation.swift b/Asterism/Asterism/Views/ComposedTeachingPresentation.swiftindex c33db15..8bfe807 100644--- a/Asterism/Asterism/Views/ComposedTeachingPresentation.swift+++ b/Asterism/Asterism/Views/ComposedTeachingPresentation.swift@@ -2,15 +2,28 @@ import AsterismCore import SwiftUI  /// Static presentation constants and pure selection helpers for the composed-/// teaching surface's URL details editor (Req 8.2–8.4). These survive the-/// deletion of the M3 `URLTeachingView`, which owned the equivalent helpers.+/// teaching surface — the two-granularity title chip selector (Req 3.3, 8.3, 8.6)+/// and the URL details editor (Req 8.2–8.4). These survive the deletion of the M3+/// `URLTeachingView`, which owned the equivalent URL helpers. public enum ComposedTeachingPresentation {     public static let minimumHitTarget: CGFloat = AsterismLayout.minHitTarget      // Disclosure copy (Req 8.2; final copy set by the visual design pass).     public static let disclosureLabel = "Add URL details"     public static let disclosureBenefit = "Identify Works and chapters from the page URL for re-share matching."-    public static let missingDetailsHint = "This title is missing details — add URL details to source the chapter."+    /// Shown when the title selection sources no chapter. The disclosure is also+    /// auto-expanded in that state: it *is* the remedy, not a passive hint+    /// beside a collapsed section (Req 8.2 as amended by Decision 8).+    public static let chapterRemedyLabel = "Take the chapter from the URL"+    public static let chapterRemedyHint =+        "This title names the Work but no chapter. Pick the part of the URL that numbers the chapter."++    /// Shown when a chip tap cannot be applied because the selection would be+    /// left with nothing naming the Work. A taught site always holds a Work+    /// source (Req 1.1, Decision 5), so the tap is refused — but with a reason+    /// rather than in silence (Req 8.4, 8.6).+    public static let lastWorkNotice =+        "Every site needs a Work name. Mark another part as the Work before changing this one."      public static let workSlotLabel = "Work identity"     public static let sequenceSlotLabel = "Chapter sequence"@@ -71,6 +84,230 @@ public enum ComposedTeachingPresentation {         return URLTwoFieldSelection(work: 0..<(count - 1), sequence: (count - 1)..<count)     } +    // MARK: - Two-granularity title chip selection (Req 3.3, 8.3, 8.6)++    /// A delimiter-split segment of the example title with its character range.+    public struct TitleSegment: Equatable, Sendable {+        public let text: String+        public let range: Range<Int>+    }++    /// One tappable title chip: a whole delimiter-split **segment** by default,+    /// or one **part** (maximal alphanumeric run) of a segment the reader has+    /// subdivided in place.+    public struct TitleChip: Equatable, Sendable {+        public let text: String+        /// Character range within the example title.+        public let range: Range<Int>+        public let segmentIndex: Int+        public let isPart: Bool+        /// Whether tapping this chip's split control subdivides it (whole+        /// segments holding two or more parts only).+        public let canSubdivide: Bool+    }++    /// The title rule a chip selection authors. The form is inferred from the+    /// selection and never chosen by the reader (Req 8.6).+    public struct InferredTitleRule: Equatable, Sendable {+        public let definition: PatternDefinition+        public let trimPrefix: String?+        public let trimSuffix: String?+    }++    /// The example title's delimiter-split segments with their character ranges.+    /// A title the tokenizer rejects is one segment covering the whole title.+    public static func titleSegments(in title: String) -> [TitleSegment] {+        let whole = [TitleSegment(text: title, range: 0..<title.count)]+        guard case .success(let tokenized) = DelimiterTokenizer.tokenize(title),+              tokenized.segments.count == tokenized.delimiters.count + 1 else { return whole }+        let characterOffsets = characterOffsetsByScalarOffset(in: title)+        let scalarCount = title.unicodeScalars.count+        var result: [TitleSegment] = []+        for index in tokenized.segments.indices {+            let startScalar = index == 0 ? 0 : tokenized.delimiters[index - 1].end+            let endScalar = index < tokenized.delimiters.count ? tokenized.delimiters[index].start : scalarCount+            guard let lower = characterOffsets[startScalar],+                  let upper = characterOffsets[endScalar], lower < upper else { return whole }+            result.append(TitleSegment(text: tokenized.segments[index], range: lower..<upper))+        }+        return result.isEmpty ? whole : result+    }++    /// The chip row for a set of subdivided segment indices. Subdividing happens+    /// in place: the subdivided segment's chip is replaced by its part chips in+    /// the same row (Req 8.3 — no sheet, no second screen).+    public static func titleChips(segments: [TitleSegment], subdividing: Set<Int>) -> [TitleChip] {+        var chips: [TitleChip] = []+        for (index, segment) in segments.enumerated() {+            let parts = tokenRanges(in: segment.text)+            let characters = Array(segment.text)+            if subdividing.contains(index), parts.count >= 2 {+                for part in parts {+                    chips.append(TitleChip(+                        text: String(characters[part]),+                        range: (segment.range.lowerBound + part.lowerBound)+                            ..< (segment.range.lowerBound + part.upperBound),+                        segmentIndex: index, isPart: true, canSubdivide: false))+                }+            } else {+                chips.append(TitleChip(+                    text: segment.text, range: segment.range,+                    segmentIndex: index, isPart: false, canSubdivide: parts.count >= 2))+            }+        }+        return chips+    }++    /// Keeps each role's chips one contiguous run. When a tap breaks contiguity,+    /// the run holding the tapped chip survives (otherwise the longest run, ties+    /// to the later one) and the rest fall back to ignore, so every tap lands on+    /// a selection that can author a rule (Req 8.6).+    public static func normalizedRoles(_ roles: [SegmentRole], changedIndex: Int) -> [SegmentRole] {+        var result = roles+        for role in [SegmentRole.work, .chapter] {+            let runs = contiguousRuns(of: role, in: result)+            guard runs.count > 1 else { continue }+            let kept = runs.first { $0.contains(changedIndex) }+                ?? runs.max { lhs, rhs in+                    lhs.count == rhs.count ? lhs.lowerBound < rhs.lowerBound : lhs.count < rhs.count+                }+            for run in runs where run != kept {+                for index in run { result[index] = .ignore }+            }+        }+        return result+    }++    /// Infers the title rule form from what the reader selected (Req 8.6's+    /// table). Returns nil for a selection that cannot author a legal rule; the+    /// caller uses that to make such selections unreachable rather than+    /// reporting a validation error afterwards.+    public static func inferredTitleRule(+        title: String, segments: [TitleSegment], chips: [TitleChip], roles: [SegmentRole]+    ) -> InferredTitleRule? {+        guard !chips.isEmpty, chips.count == roles.count else { return nil }+        let workIndices = roles.indices.filter { roles[$0] == .work }+        let chapterIndices = roles.indices.filter { roles[$0] == .chapter }+        guard !workIndices.isEmpty, isContiguous(workIndices), isContiguous(chapterIndices) else { return nil }++        // Entire title marked as Work → whole-title rule, no trims.+        if chapterIndices.isEmpty, workIndices.count == chips.count {+            return InferredTitleRule(definition: .wholeTitle, trimPrefix: nil, trimSuffix: nil)+        }++        let selectionUsesParts = (workIndices + chapterIndices).contains { chips[$0].isPart }+        if !selectionUsesParts {+            return segmentFormRule(+                segments: segments, chips: chips, roles: roles, hasChapter: !chapterIndices.isEmpty)+        }++        let workSpan = chips[workIndices.first!].range.lowerBound..<chips[workIndices.last!].range.upperBound+        guard !chapterIndices.isEmpty else {+            // Subdivided parts, Work only → whole-title rule plus the trims the+            // discarded leading and trailing text authors.+            let trims = WholeTitleRuleDeriver.trims(from: title, keptSpan: workSpan)+            return InferredTitleRule(definition: .wholeTitle, trimPrefix: trims.prefix, trimSuffix: trims.suffix)+        }+        // Subdivided parts, Work and chapter → phrase. `.phrase` already means+        // "exact literals around and between two fields", so this needs no new+        // `PatternDefinition` arm; adjacent spans throw here (blank separator)+        // and the caller keeps that selection unreachable.+        let chapterSpan = chips[chapterIndices.first!].range.lowerBound+            ..< chips[chapterIndices.last!].range.upperBound+        guard let definition = try? PhrasePatternDeriver.derive(+            from: title, selection: PhraseSelection(chapter: chapterSpan, work: workSpan)) else { return nil }+        return InferredTitleRule(definition: definition, trimPrefix: nil, trimSuffix: nil)+    }++    /// The chip selection that reproduces a Work span and an optional chapter+    /// span — used to re-seed the selector from the rule a Site already holds.+    /// Segments a span cuts through are subdivided so the boundary is expressible.+    public static func titleSelection(+        in title: String, workSpan: Range<Int>, chapterSpan: Range<Int>?+    ) -> (subdivided: Set<Int>, roles: [SegmentRole]) {+        let segments = titleSegments(in: title)+        var subdivided: Set<Int> = []+        for (index, segment) in segments.enumerated()+        where splits(segment.range, by: workSpan) || (chapterSpan.map { splits(segment.range, by: $0) } ?? false) {+            subdivided.insert(index)+        }+        let chips = titleChips(segments: segments, subdividing: subdivided)+        var roles = [SegmentRole](repeating: .ignore, count: chips.count)+        for (index, chip) in chips.enumerated() {+            if chip.range.lowerBound >= workSpan.lowerBound, chip.range.upperBound <= workSpan.upperBound {+                roles[index] = .work+            } else if let chapterSpan, chip.range.lowerBound >= chapterSpan.lowerBound,+                      chip.range.upperBound <= chapterSpan.upperBound {+                roles[index] = .chapter+            }+        }+        return (subdivided, roles)+    }++    // MARK: - Title selection helpers (private)++    private static func segmentFormRule(+        segments: [TitleSegment], chips: [TitleChip], roles: [SegmentRole], hasChapter: Bool+    ) -> InferredTitleRule? {+        var segmentRoles = [SegmentRole](repeating: .ignore, count: segments.count)+        for (index, chip) in chips.enumerated() where !chip.isPart {+            segmentRoles[chip.segmentIndex] = roles[index]+        }+        let texts = segments.map(\.text)+        guard hasChapter else {+            // Whole segments, Work only → chapter-less segment rule. Positional+            // matching survives title variation that exact literals do not.+            let workSegments = segmentRoles.indices.filter { segmentRoles[$0] == .work }+            guard let first = workSegments.first, let last = workSegments.last,+                  isContiguous(workSegments),+                  let anchor = AnchorDerivation.deriveWorkAnchor(+                    segmentCount: segments.count, workRange: first..<(last + 1)) else { return nil }+            return InferredTitleRule(+                definition: .chapterlessSegment(work: anchor, ignored: []), trimPrefix: nil, trimSuffix: nil)+        }+        let assignment = SegmentRoleAssignment(roles: segmentRoles)+        guard case .success(let validated) = TeachingValidator.validate(assignment: assignment, segments: texts),+              let definition = PatternDeriver.deriveSegmentPattern(from: validated) else { return nil }+        return InferredTitleRule(definition: definition, trimPrefix: nil, trimSuffix: nil)+    }++    private static func contiguousRuns(of role: SegmentRole, in roles: [SegmentRole]) -> [Range<Int>] {+        var runs: [Range<Int>] = []+        var start: Int?+        for index in roles.indices {+            if roles[index] == role {+                if start == nil { start = index }+            } else if let lower = start {+                runs.append(lower..<index)+                start = nil+            }+        }+        if let lower = start { runs.append(lower..<roles.count) }+        return runs+    }++    private static func isContiguous(_ indices: [Int]) -> Bool {+        guard let first = indices.first, let last = indices.last else { return true }+        return indices.count == last - first + 1+    }++    /// Whether a span's boundary falls strictly inside a segment.+    private static func splits(_ segment: Range<Int>, by span: Range<Int>) -> Bool {+        (span.lowerBound > segment.lowerBound && span.lowerBound < segment.upperBound)+            || (span.upperBound > segment.lowerBound && span.upperBound < segment.upperBound)+    }++    private static func characterOffsetsByScalarOffset(in title: String) -> [Int: Int] {+        var offsets: [Int: Int] = [:]+        var scalarOffset = 0+        for (characterOffset, character) in title.enumerated() {+            offsets[scalarOffset] = characterOffset+            scalarOffset += character.unicodeScalars.count+        }+        offsets[scalarOffset] = title.count+        return offsets+    }+     /// Reader-actionable wording for split derivation failures.     static func splitGuidance(for error: URLTemplateSelectionError) -> String {         switch error {
Asterism/Asterism/Views/ComposedTeachingView.swift Modified +119 / -202
diff --git a/Asterism/Asterism/Views/ComposedTeachingView.swift b/Asterism/Asterism/Views/ComposedTeachingView.swiftindex 5233b4e..53d22f5 100644--- a/Asterism/Asterism/Views/ComposedTeachingView.swift+++ b/Asterism/Asterism/Views/ComposedTeachingView.swift@@ -62,7 +62,9 @@ struct ComposedTeachingView: View {             EmptyView()         case .ready, .previewing, .previewReady:             editorContent-            if model.state == .previewReady || model.state == .previewing {+            if model.articlesRequested {+                articlesConfirmButton+            } else if model.state == .previewReady || model.state == .previewing {                 confirmButton             }         }@@ -73,11 +75,15 @@ struct ComposedTeachingView: View {     @ViewBuilder     private var editorContent: some View {         titleExampleSection-        titleModePicker-        titleEditor-        urlDisclosure-        if let outcome = model.previewOutcome {-            previewSection(outcome)+        if model.articlesRequested {+            articlesConfirmation+        } else {+            titleChipSelector+            urlDisclosure+            if let outcome = model.previewOutcome {+                previewSection(outcome)+            }+            articlesAffordance         }     } @@ -91,82 +97,41 @@ struct ComposedTeachingView: View {         .frame(maxWidth: .infinity, alignment: .leading)     } -    // MARK: - Title mode+    // MARK: - Two-granularity title chip selector (Req 3.3, 8.3, 8.6) -    private var titleModePicker: some View {-        HStack(spacing: 8) {-            modeButton(.wholeTitle, label: "Whole title", id: "composed-mode-whole-title")-            modeButton(.segments, label: "Segments", id: "composed-mode-segments")-            if model.supportsPhraseTeaching {-                modeButton(.phrase, label: "Phrase", id: "composed-mode-phrase")-            }-            if model.supportsArticles {-                modeButton(.articles, label: "Articles", id: "composed-mode-articles")-            }-        }-        .frame(maxWidth: .infinity, alignment: .leading)-        .accessibilityElement(children: .contain)-    }--    private func modeButton(_ mode: ComposedTeachingViewModel.TitleMode, label: String, id: String) -> some View {-        Button {-            model.selectTitleMode(mode)-        } label: {-            Text(label).frame(minHeight: AsterismLayout.minHitTarget)-        }-        .buttonStyle(.bordered)-        .tint(model.titleMode == mode ? AsterismColors.cyanDark : .secondary)-        .frame(minHeight: AsterismLayout.minHitTarget)-        .accessibilityIdentifier(id)-        .accessibilityAddTraits(model.titleMode == mode ? .isSelected : [])-    }--    @ViewBuilder-    private var titleEditor: some View {-        switch model.titleMode {-        case .wholeTitle: keptSpanSelector-        case .segments: segmentEditor-        case .phrase: phraseEditor-        case .articles: articlesEditor-        }-    }--    // MARK: - Kept-span selector (Req 3.3)--    private var keptSpanSelector: some View {-        let characters = Array(model.exampleTitle)-        let tokens = model.titleTokenRanges-        return VStack(alignment: .leading, spacing: 12) {-            Text("Tap the parts of the title that name the Work — tap an end part again to trim it off.")+    /// Segments by default; a selected segment's scissors control subdivides it in+    /// place into parts. The rule form is inferred from what is selected and is+    /// never named by the reader, so there is no title-mode picker (Decision 8).+    private var titleChipSelector: some View {+        VStack(alignment: .leading, spacing: 12) {+            Text(model.isSubdivided+                 ? "Tap a part to change what it supplies. Whole segments cope with changing titles better than parts do."+                 : "Tap a segment to say what it supplies. Use the scissors to work inside a segment.")                 .font(.caption).foregroundStyle(.secondary) -            if tokens.count >= 2 {-                FlowLayout(spacing: 6) {-                    ForEach(Array(tokens.enumerated()), id: \.offset) { index, tokenRange in-                        let isKept = tokenRange.overlaps(model.keptSpan)-                        Button {-                            model.toggleTitleToken(at: index)-                        } label: {-                            Text(String(characters[tokenRange]))-                                .font(.subheadline)-                                .lineLimit(1)-                                .padding(.horizontal, 10)-                                .padding(.vertical, 8)-                                .frame(minHeight: AsterismLayout.minHitTarget)-                                .background((isKept ? AsterismColors.amberDark : Color.secondary).opacity(0.12))-                                .clipShape(Capsule())-                                .overlay(Capsule().stroke((isKept ? AsterismColors.amberDark : Color.secondary).opacity(0.4), lineWidth: 1))-                        }-                        .buttonStyle(.plain)-                        .accessibilityIdentifier("composed-title-token-\(index)")-                        .accessibilityLabel("\(String(characters[tokenRange])), \(isKept ? "part of the Work name" : "trimmed")")-                        .accessibilityAddTraits(isKept ? .isSelected : [])-                    }+            FlowLayout(spacing: 6) {+                ForEach(Array(model.titleChips.enumerated()), id: \.offset) { index, chip in+                    TitleChipView(+                        text: chip.text,+                        role: model.titleRoles[index],+                        index: index,+                        onTap: { model.cycleTitleRole(at: index) },+                        onSubdivide: chip.canSubdivide && model.titleRoles[index] != .ignore+                            ? { model.subdivideSegment(atChip: index) }+                            : nil)                 }-                .accessibilityElement(children: .contain)-                .accessibilityIdentifier("composed-title-tokens")--                charBoundaryControls+            }+            .accessibilityElement(children: .contain)+            .accessibilityIdentifier("composed-title-chips")++            if let notice = model.titleSelectionNotice {+                // A refused tap says why instead of doing nothing (Req 8.4). The+                // symbol carries the meaning alongside the color, and the label+                // is one static-text element VoiceOver reads.+                Label(notice, systemImage: "exclamationmark.circle")+                    .font(.caption)+                    .foregroundStyle(AsterismColors.amberDark)+                    .accessibilityIdentifier("composed-title-selection-notice")             }              LabeledContent("Work will be named") {@@ -176,128 +141,85 @@ struct ComposedTeachingView: View {             }             .accessibilityIdentifier("composed-work-name-preview") -            if model.trimPrefix != nil || model.trimSuffix != nil {-                Button("Keep the whole title") {-                    model.resetKeptSpanToWholeTitle()-                }-                .font(.footnote)-                .frame(minHeight: AsterismLayout.minHitTarget)-                .accessibilityIdentifier("composed-title-reset")-            }-        }-        .frame(maxWidth: .infinity, alignment: .leading)-    }--    /// The M3 character-level boundary controls, retained for sub-token precision.-    private var charBoundaryControls: some View {-        HStack(spacing: 16) {-            boundaryStepper(label: "Start", id: "composed-title-start",-                            back: { model.adjustKeptSpanStart(by: -1) },-                            forward: { model.adjustKeptSpanStart(by: 1) })-            boundaryStepper(label: "End", id: "composed-title-end",-                            back: { model.adjustKeptSpanEnd(by: -1) },-                            forward: { model.adjustKeptSpanEnd(by: 1) })-        }-    }--    private func boundaryStepper(label: String, id: String, back: @escaping () -> Void, forward: @escaping () -> Void) -> some View {-        HStack(spacing: 6) {-            Text(label).font(.caption).foregroundStyle(.secondary)-            Button { back() } label: {-                Image(systemName: "chevron.left")-                    .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)-            }-            .accessibilityIdentifier("\(id)-back")-            .accessibilityLabel("Move \(label.lowercased()) boundary left one character")-            Button { forward() } label: {-                Image(systemName: "chevron.right")-                    .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)-            }-            .accessibilityIdentifier("\(id)-forward")-            .accessibilityLabel("Move \(label.lowercased()) boundary right one character")-        }-    }--    // MARK: - Segment editor (WC, preserved M2 behavior)--    private var segmentEditor: some View {-        VStack(alignment: .leading, spacing: 8) {-            Text("Tap each segment to assign its role").font(.caption).foregroundStyle(.secondary)-            FlowLayout(spacing: 8) {-                ForEach(Array(model.segments.enumerated()), id: \.offset) { index, segment in-                    SegmentChipView(text: segment, role: model.roles[index], index: index,-                                    onTap: { model.cycleRole(at: index) })+            if let chapter = model.chapterNamePreview {+                LabeledContent("Chapter will be named") {+                    Text(chapter).font(.subheadline).foregroundStyle(AsterismColors.cyanDark)                 }+                .accessibilityIdentifier("composed-chapter-name-preview")             }-            if let error = model.validationError {-                Text(Self.validationMessage(for: error))-                    .font(.caption).foregroundStyle(.orange)-                    .accessibilityIdentifier("composed-segment-validation")-            }-        }-        .accessibilityElement(children: .contain)-        .accessibilityIdentifier("composed-segments")-    } -    private var phraseEditor: some View {-        VStack(alignment: .leading, spacing: 12) {-            Text("Set the Chapter and Work text one character at a time.")-                .font(.caption).foregroundStyle(.secondary)-            phraseCard(role: .chapter, label: "Chapter")-            phraseCard(role: .work, label: "Work")-            if let message = model.phraseValidationMessage {-                Text(message).font(.caption).foregroundStyle(.red)-                    .accessibilityIdentifier("composed-phrase-validation")+            if model.isSubdivided {+                Button("Use whole segments") { model.useWholeSegments() }+                    .font(.footnote)+                    .frame(minHeight: AsterismLayout.minHitTarget)+                    .accessibilityIdentifier("composed-title-use-segments")             }         }-        .accessibilityElement(children: .contain)-        .accessibilityIdentifier("composed-phrase-editor")+        .frame(maxWidth: .infinity, alignment: .leading)     } -    private func phraseCard(role: PhraseFieldRole, label: String) -> some View {-        VStack(alignment: .leading, spacing: 8) {-            Text("\(label): \(model.selectedText(for: role))")-                .font(.caption.bold())-                .accessibilityIdentifier("composed-phrase-\(role.rawValue)")-            HStack(spacing: 12) {-                phraseBoundary(role: role, boundary: .start, label: "Start")-                phraseBoundary(role: role, boundary: .end, label: "End")-            }-        }-        .padding(8)-        .overlay(RoundedRectangle(cornerRadius: 6).stroke(.secondary.opacity(0.25)))-    }+    // MARK: - Articles affordance (Req 8.7) -    private func phraseBoundary(role: PhraseFieldRole, boundary: ComposedTeachingViewModel.PhraseBoundary, label: String) -> some View {-        HStack(spacing: 4) {-            Text(label).font(.caption).foregroundStyle(.secondary)-            Button { model.adjustPhraseBoundary(role, boundary: boundary, direction: .backward) } label: {-                Image(systemName: "chevron.left").frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)-            }-            .accessibilityLabel("Move \(role.rawValue) \(label.lowercased()) back")-            Button { model.adjustPhraseBoundary(role, boundary: boundary, direction: .forward) } label: {-                Image(systemName: "chevron.right").frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+    /// Articles is a one-way Site transition, not a title rule form, so it sits+    /// apart from title selection with its own confirmation of the consequences.+    @ViewBuilder+    private var articlesAffordance: some View {+        if model.supportsArticles {+            VStack(alignment: .leading, spacing: 8) {+                Divider()+                Text("Not a site with chapters?").font(.caption).foregroundStyle(.secondary)+                Button("Treat every page as an article") { model.requestArticles() }+                    .font(.footnote)+                    .frame(minHeight: AsterismLayout.minHitTarget)+                    .accessibilityIdentifier("composed-articles-affordance")             }-            .accessibilityLabel("Move \(role.rawValue) \(label.lowercased()) forward")+            .frame(maxWidth: .infinity, alignment: .leading)         }     } -    private var articlesEditor: some View {+    private var articlesConfirmation: some View {         VStack(alignment: .leading, spacing: 12) {-            Text("Treat every page as an independent article.")-                .font(.caption).foregroundStyle(.secondary)+            Label("Treat every page as an article", systemImage: "doc.plaintext")+                .font(.subheadline.weight(.semibold))+            Text("This retires the site's title rule and its URL rule. Entries stop being chapters of a Work and stand on their own.")+                .font(.footnote).foregroundStyle(.secondary)             Stepper("Remove last \(model.junkSuffixSegmentCount) title segment(s)",                     value: Binding(get: { model.junkSuffixSegmentCount },                                    set: { model.setJunkSuffixSegmentCount($0) }),-                    in: 0...max(0, model.segments.count - 1))+                    in: 0...max(0, model.titleSegments.count - 1))                 .frame(minHeight: AsterismLayout.minHitTarget)                 .accessibilityIdentifier("composed-articles-stepper")             if let message = model.articleValidationMessage {                 Text(message).font(.caption).foregroundStyle(.red)             }+            if let count = model.articlesPreviewEntryCount {+                Text("^[\(count) entry](inflect: true) will become independent articles.")+                    .font(.footnote)+                    .accessibilityIdentifier("composed-articles-preview")+            }+            Button("Keep teaching a title instead") { model.cancelArticles() }+                .font(.footnote)+                .frame(minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("composed-articles-cancel")         }+        .padding(12)+        .background(.fill.quaternary)+        .clipShape(RoundedRectangle(cornerRadius: 8))         .accessibilityElement(children: .contain)-        .accessibilityIdentifier("composed-articles-editor")+        .accessibilityIdentifier("composed-articles-confirmation")+    }++    private var articlesConfirmButton: some View {+        Button {+            Task { await model.confirmArticles() }+        } label: {+            Text("Turn on Articles mode")+                .font(.headline)+                .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget)+        }+        .buttonStyle(.borderedProminent)+        .disabled(!model.canConfirmArticles)+        .accessibilityIdentifier("composed-articles-confirm")     }      // MARK: - URL details disclosure (Req 8.2, 8.4)@@ -309,29 +231,31 @@ struct ComposedTeachingView: View {                 set: { $0 ? model.expandDisclosure() : model.collapseDisclosure() })         ) {             VStack(alignment: .leading, spacing: 12) {+                // The editor owns the single clear affordance+                // ("composed-url-clear"); a second one here would clear the+                // definition without resetting the editor's own selections.                 ComposedURLDetailsEditor(                     exampleRawURL: model.exampleRawURL,+                    currentDefinition: model.urlRuleDefinition,                     currentSummary: model.urlSelectionSummary,                     onRuleChange: { model.setURLRuleDefinition($0) })-                if model.urlRuleDefinition != nil {-                    Button("Clear URL details") { model.clearURLSelection() }-                        .font(.footnote)-                        .frame(minHeight: AsterismLayout.minHitTarget)-                        .accessibilityIdentifier("composed-url-clear-all")-                }             }             .padding(.top, 4)         } label: {             VStack(alignment: .leading, spacing: 2) {-                Text(ComposedTeachingPresentation.disclosureLabel)+                Text(model.chapterUnsourced+                     ? ComposedTeachingPresentation.chapterRemedyLabel+                     : ComposedTeachingPresentation.disclosureLabel)                     .font(.subheadline.weight(.medium))                 if let summary = model.urlSelectionSummary, model.disclosureState == .collapsed {                     Text(summary).font(.caption).foregroundStyle(.secondary)                         .accessibilityIdentifier("composed-url-collapsed-summary")-                } else if model.missingDetailsHint {-                    Text(ComposedTeachingPresentation.missingDetailsHint)+                } else if model.chapterUnsourced {+                    // The disclosure is the remedy, not a passive hint beside a+                    // collapsed section (Req 8.2 as amended); it auto-expands.+                    Text(ComposedTeachingPresentation.chapterRemedyHint)                         .font(.caption).foregroundStyle(AsterismColors.amberDark)-                        .accessibilityIdentifier("composed-url-missing-hint")+                        .accessibilityIdentifier("composed-url-remedy-hint")                 } else {                     Text(ComposedTeachingPresentation.disclosureBenefit)                         .font(.caption).foregroundStyle(.secondary)@@ -402,16 +326,22 @@ struct ComposedTeachingView: View {                 .foregroundStyle(AsterismColors.amberDark)             Text("This title names the Work but does not identify a chapter, and no chapter comes from the URL. Add URL details to source the chapter, or teach anyway and leave chapters unsettled.")                 .font(.footnote).foregroundStyle(.secondary)-            Button("Teach anyway") {+            Button {                 Task { await model.acknowledgeAndConfirm() }+            } label: {+                Text("Teach anyway")+                    .font(.headline)+                    .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget)             }             .buttonStyle(.borderedProminent)-            .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget)             .accessibilityIdentifier("composed-acknowledge-confirm")         }         .padding(12)         .background(AsterismColors.amberDark.opacity(0.1))         .clipShape(RoundedRectangle(cornerRadius: 8))+        // Without `children: .contain` the identifier collapses the panel into a+        // single element and hides its own confirm button from assistive tech.+        .accessibilityElement(children: .contain)         .accessibilityIdentifier("composed-acknowledgment")     } @@ -485,17 +415,4 @@ struct ComposedTeachingView: View {         .frame(maxWidth: .infinity, minHeight: 200)         .accessibilityIdentifier("composed-teaching-error")     }--    // MARK: - Helpers--    private static func validationMessage(for error: TeachingValidationError) -> String {-        switch error {-        case .noWorkSegment: "Select at least one Work segment (or use Whole title)"-        case .noChapterSegment: "Select at least one Chapter segment"-        case .nonContiguousWork: "Work segments must be contiguous"-        case .nonContiguousChapter: "Chapter segments must be contiguous"-        case .workChapterOverlap: "Work and Chapter cannot overlap"-        case .blankField(let field): "\(field.capitalized) must not be blank"-        }-    } }
Asterism/Asterism/Views/ComposedURLDetailsEditor.swift Modified +87 / -14
diff --git a/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift b/Asterism/Asterism/Views/ComposedURLDetailsEditor.swiftindex 9cf9916..ca64617 100644--- a/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift+++ b/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift@@ -15,7 +15,12 @@ private enum URLComponentSelection: Equatable { /// deleted M3 `URLTeachingView` so the same chip/split editors are reused. struct ComposedURLDetailsEditor: View {     let exampleRawURL: String-    /// The current (retained) URL rule, so re-teaching can show what is in effect.+    /// The current (retained) URL rule, so re-teaching opens on what is in effect.+    /// The editor's chip selections are seeded from it; without that, a single tap+    /// would rebuild the definition from empty local state and silently narrow a+    /// retained `.workAndSequence` rule to `.work` or `.sequence` (Q11).+    let currentDefinition: URLRuleDefinition?+    /// The current (retained) URL rule summarised for display.     let currentSummary: String?     let onRuleChange: (URLRuleDefinition?) -> Void @@ -24,6 +29,9 @@ struct ComposedURLDetailsEditor: View {     @State private var sequenceSelection: URLComponentSelection?     @State private var splitSelection: URLTwoFieldSelection?     @State private var splitErrorMessage: String?+    /// The definition the local selections were last seeded from, so an external+    /// change (including an explicit clear) re-seeds rather than drifting.+    @State private var seededFrom: URLRuleDefinition??      enum Slot: Equatable { case work, sequence } @@ -33,7 +41,7 @@ struct ComposedURLDetailsEditor: View {      var body: some View {         VStack(alignment: .leading, spacing: 12) {-            if let summary = currentSummary, workSelection == nil {+            if let summary = currentSummary, workSelection == nil, sequenceSelection == nil {                 LabeledContent("Current rule") {                     Text(summary).font(.footnote).foregroundStyle(.secondary)                 }@@ -54,6 +62,62 @@ struct ComposedURLDetailsEditor: View {         }         .accessibilityElement(children: .contain)         .accessibilityIdentifier("composed-url-editor")+        .onAppear { seedIfNeeded() }+        .onChange(of: currentDefinition) { seedIfNeeded() }+    }++    // MARK: - Seeding from the retained rule++    /// Mirror the owning view model's definition into the local selections. Runs+    /// on appear and whenever the definition changes from outside the editor+    /// (re-teach load, or the surface's explicit clear).+    private func seedIfNeeded() {+        guard seededFrom != .some(currentDefinition) else { return }+        seededFrom = .some(currentDefinition)+        splitErrorMessage = nil++        guard let definition = currentDefinition, let components else {+            workSelection = nil+            sequenceSelection = nil+            splitSelection = nil+            return+        }++        switch definition {+        case .work(let locator):+            workSelection = selection(for: locator, in: components)+            sequenceSelection = nil+            splitSelection = nil+        case .sequence(let locator):+            workSelection = nil+            sequenceSelection = selection(for: locator, in: components)+            splitSelection = nil+            activeSlot = .sequence+        case .workAndSequence(let work, let sequence):+            workSelection = selection(for: work.locator, in: components)+            sequenceSelection = selection(for: sequence.locator, in: components)+            splitSelection = nil+        case .combined(let locator, _):+            // The within-component split is not reversed: a derived template does+            // not identify a unique character selection. The Work component is+            // restored so the retained rule stays visible and re-derivable.+            workSelection = selection(for: locator, in: components)+            sequenceSelection = nil+            splitSelection = nil+        }+    }++    /// Resolve a stored locator back to the example URL's chip index.+    private func selection(+        for locator: URLComponentLocator, in components: RawURLLexicalComponents+    ) -> URLComponentSelection? {+        if case .query(let name) = locator {+            guard let index = components.queryItems.firstIndex(where: { $0.name == name }) else { return nil }+            return .query(index)+        }+        guard let value = try? URLRuleApplicator.select(locator, from: components),+              let index = components.pathComponents.firstIndex(of: value) else { return nil }+        return .path(index)     }      // MARK: - Example URL@@ -125,7 +189,7 @@ struct ComposedURLDetailsEditor: View {                 sequenceSelection = nil                 splitSelection = nil                 splitErrorMessage = nil-                onRuleChange(nil)+                publish(nil)             }             .font(.footnote)             .frame(minHeight: ComposedTeachingPresentation.minimumHitTarget)@@ -331,42 +395,51 @@ struct ComposedURLDetailsEditor: View {         dispatchRuleDefinition()     } +    /// Publishes a locally-authored definition. Records it as the seed so the+    /// `onChange` that follows recognises it as the editor's own work and leaves+    /// the local selections alone — without this, every edit round-trips through+    /// the view model and re-seeds, clearing the split selection mid-edit.+    private func publish(_ definition: URLRuleDefinition?) {+        seededFrom = .some(definition)+        onRuleChange(definition)+    }+     /// Builds and dispatches the URL rule definition from the current selections:     /// a Work-identity rule, a sequence-only rule (chapter-from-URL), an     /// identity+sequence pair, or a within-component combined template.     private func dispatchRuleDefinition() {-        guard let components else { onRuleChange(nil); return }+        guard let components else { publish(nil); return }          // Sequence-only rule: the sequence slot is filled and the Work slot empty         // (the whole title names the Work; the URL supplies only the chapter).         if workSelection == nil, let sequenceSel = sequenceSelection {             guard let sequenceLocator = locator(for: sequenceSel, in: components) else {-                onRuleChange(nil); return+                publish(nil); return             }-            onRuleChange(.sequence(locator: sequenceLocator))+            publish(.sequence(locator: sequenceLocator))             return         }          guard let workSel = workSelection,               let workLocator = locator(for: workSel, in: components) else {-            onRuleChange(nil)+            publish(nil)             return         }          // Within-component combined template (Work + chapter in one component).         if let split = splitSelection {-            guard let text = componentText(for: workSel, in: components) else { onRuleChange(nil); return }+            guard let text = componentText(for: workSel, in: components) else { publish(nil); return }             do {                 let template = try URLTwoFieldTemplateDeriver.derive(                     from: ExactScalarString(text), selection: split)                 splitErrorMessage = nil-                onRuleChange(.combined(locator: workLocator, template: template))+                publish(.combined(locator: workLocator, template: template))             } catch let error as URLTemplateSelectionError {                 splitErrorMessage = ComposedTeachingPresentation.splitGuidance(for: error)-                onRuleChange(nil)+                publish(nil)             } catch {                 splitErrorMessage = String(describing: error)-                onRuleChange(nil)+                publish(nil)             }             return         }@@ -376,17 +449,17 @@ struct ComposedURLDetailsEditor: View {             guard sequenceSel != workSel,                   let sequenceLocator = locator(for: sequenceSel, in: components),                   sequenceLocator != workLocator else {-                onRuleChange(nil)+                publish(nil)                 return             }-            onRuleChange(.workAndSequence(+            publish(.workAndSequence(                 work: URLFieldSelector(locator: workLocator),                 sequence: URLFieldSelector(locator: sequenceLocator)))             return         }          // Work-identity only.-        onRuleChange(.work(locator: workLocator))+        publish(.work(locator: workLocator))     }      private func locator(for selection: URLComponentSelection, in components: RawURLLexicalComponents) -> URLComponentLocator? {
Asterism/Asterism/Views/RecentView.swift Modified +2 / -2
diff --git a/Asterism/Asterism/Views/RecentView.swift b/Asterism/Asterism/Views/RecentView.swiftindex ca816e9..9de6a95 100644--- a/Asterism/Asterism/Views/RecentView.swift+++ b/Asterism/Asterism/Views/RecentView.swift@@ -5,7 +5,7 @@ import SwiftUI /// Built from an immutable RecentPresentation DTO (repository-owned derivation). /// Banner is a 44pt Button that toggles actionable-only filtering. /// Rows show Work display title (not raw capture title), inline Teach/Re-teach action-/// that opens TeachingView directly.+/// that opens the composed teaching surface directly (Req 8.1). struct RecentView: View {     let presentation: RecentPresentation     let capabilities: AsterismCapabilities@@ -120,7 +120,7 @@ struct RecentView: View {  /// A single row in the Recent list: shows Work display title for taught entries, /// raw capture title for untaught. Actionable rows show amber treatment with-/// inline Teach/Re-teach pill that opens TeachingView directly.+/// inline Teach/Re-teach pill that opens the composed teaching surface directly. struct RecentEntryRow: View {     let row: RecentPresentationRow     let onSelect: (UUID) -> Void
Asterism/Asterism/Views/TeachingComponents.swift Modified +55 / -21
diff --git a/Asterism/Asterism/Views/TeachingComponents.swift b/Asterism/Asterism/Views/TeachingComponents.swiftindex 93fead4..484408e 100644--- a/Asterism/Asterism/Views/TeachingComponents.swift+++ b/Asterism/Asterism/Views/TeachingComponents.swift@@ -1,35 +1,61 @@ import AsterismCore import SwiftUI -/// A segment chip with a role symbol, used by the composed surface's segment-/// editor. Tapping cycles the segment's role.-struct SegmentChipView: View {+/// One chip of the composed surface's two-granularity title selector (Req 8.3,+/// Decision 8). The leading control cycles what the chip supplies; a chip that is+/// already selected and holds two or more parts carries a trailing split control+/// that subdivides it in place, in the same row — no sheet and no second screen.+/// The role letter and the selected trait carry the state without relying on+/// color (Req 8.4).+struct TitleChipView: View {     let text: String     let role: SegmentRole     let index: Int     let onTap: () -> Void+    let onSubdivide: (() -> Void)?      var body: some View {-        Button(action: onTap) {-            HStack(spacing: 4) {-                Text(roleSymbol)-                    .font(.caption.monospaced().bold())-                    .foregroundStyle(roleColor)-                Text(text)-                    .font(.subheadline)-                    .lineLimit(1)+        HStack(spacing: 0) {+            Button(action: onTap) {+                HStack(spacing: 4) {+                    Text(roleSymbol)+                        .font(.caption.monospaced().bold())+                        .foregroundStyle(roleColor)+                    Text(text)+                        .font(.subheadline)+                        .lineLimit(1)+                }+                .padding(.horizontal, 12)+                .padding(.vertical, 8)+                .frame(minHeight: AsterismLayout.minHitTarget)+            }+            .buttonStyle(.plain)+            .accessibilityIdentifier("composed-title-chip-\(index)")+            .accessibilityLabel("\(text), \(roleDescription)")+            .accessibilityHint("Tap to change what this supplies")+            .accessibilityAddTraits(role == .ignore ? [] : .isSelected)++            if let onSubdivide {+                Rectangle()+                    .fill(roleColor.opacity(0.35))+                    .frame(width: 1)+                    .frame(maxHeight: .infinity)+                    .accessibilityHidden(true)+                Button(action: onSubdivide) {+                    Image(systemName: "scissors")+                        .font(.caption)+                        .padding(.horizontal, 10)+                        .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+                }+                .buttonStyle(.plain)+                .accessibilityIdentifier("composed-title-split-\(index)")+                .accessibilityLabel("Split \(text) into parts")             }-            .padding(.horizontal, 12)-            .padding(.vertical, 8)-            .frame(minHeight: AsterismLayout.minHitTarget)-            .background(roleColor.opacity(0.12))-            .clipShape(Capsule())-            .overlay(Capsule().stroke(roleColor.opacity(0.4), lineWidth: 1))         }-        .buttonStyle(.plain)-        .accessibilityIdentifier("segment-chip-\(index)")-        .accessibilityLabel("Segment \(index + 1): \(text), role: \(role.rawValue)")-        .accessibilityHint("Tap to cycle role")+        .fixedSize(horizontal: true, vertical: false)+        .background(roleColor.opacity(0.12))+        .clipShape(Capsule())+        .overlay(Capsule().stroke(roleColor.opacity(0.4), lineWidth: 1))     }      private var roleSymbol: String {@@ -40,6 +66,14 @@ struct SegmentChipView: View {         }     } +    private var roleDescription: String {+        switch role {+        case .chapter: "supplies the chapter"+        case .work: "supplies the Work name"+        case .ignore: "not used"+        }+    }+     private var roleColor: Color {         switch role {         case .chapter: AsterismColors.cyanDark
Asterism/AsterismTests/ComposedTeachingViewModelTests.swift Modified +327 / -25
diff --git a/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift b/Asterism/AsterismTests/ComposedTeachingViewModelTests.swiftindex d01d9ba..49e9536 100644--- a/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift+++ b/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift@@ -3,16 +3,17 @@ import Foundation import Testing @testable import Asterism -/// Tests for ComposedTeachingViewModel (task 18): the disclosure state machine-/// (Q11), the per-commit unsettled-chapters acknowledgment flow (Q3), and-/// generation-gated preview publication carried from URLTeachingViewModel.+/// Tests for ComposedTeachingViewModel: the disclosure state machine (Q11), the+/// per-commit unsettled-chapters acknowledgment flow (Q3), generation-gated+/// preview publication, and the two-granularity title chip selection whose rule+/// form is inferred rather than chosen (Req 8.6, Decision 8). @Suite("ComposedTeachingViewModel") @MainActor struct ComposedTeachingViewModelTests {      // MARK: - Fixtures -    private static let hostname = "example.com"+    nonisolated private static let hostname = "example.com"      private static func makeEntry(         title: String = "TtH - Story - Real Title",@@ -45,6 +46,22 @@ struct ComposedTeachingViewModelTests {             definition: .sequence(locator: .query(name: ExactScalarString("chapter"))))     } +    private static func makeArticlesContract() -> ArticlesContract {+        ArticlesContract(+            basis: TeachingBasis(+                siteMode: .untaught, hostname: hostname, patterns: [], entries: [], works: []),+            request: ArticlesRequest(junkSuffixRule: nil),+            outcome: ArticlesOutcome(+                plan: TitleProjectionPlanner.planArticles(entries: [], junkSuffixRule: nil)))+    }++    /// A retained rule that sources a chapter, so the chapter-remedy auto-expand+    /// (Req 8.2 as amended) does not fire and the disclosure default is testable.+    private static func chapterBearingContract() throws -> ComposedTeachingContract {+        makeContract(currentTitleRule: titleRuleBasis(+            definition: .segment(work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: [])))+    }+     private func makeSUT(         contract: ComposedTeachingContract? = nil,         commitOutcome: ComposedTeachingCommitOutcome = .committed(@@ -56,6 +73,8 @@ struct ComposedTeachingViewModelTests {         let c = contract ?? Self.makeContract()         mock.projectComposedTeachingResult = .success(c)         mock.commitComposedTeachingResult = .success(commitOutcome)+        mock.projectArticlesResult = .success(Self.makeArticlesContract())+        mock.commitArticlesResult = .success(.committed)         let vm = ComposedTeachingViewModel(             entry: entry ?? Self.makeEntry(), library: mock, capabilities: .m4,             entryContext: entryContext, onMutation: {})@@ -64,13 +83,23 @@ struct ComposedTeachingViewModelTests {      // MARK: - Disclosure state machine (Req 8.2, Decision 1, Q11) -    @Test("Disclosure is collapsed by default")-    func disclosureCollapsedByDefault() async {-        let (vm, _) = makeSUT()+    @Test("Disclosure is collapsed by default when the title already sources a chapter")+    func disclosureCollapsedByDefault() async throws {+        let (vm, _) = makeSUT(contract: try Self.chapterBearingContract())         await vm.load()         #expect(vm.disclosureState == .collapsed)     } +    @Test("Disclosure auto-expands as the remedy when the selection sources no chapter")+    func disclosureAutoExpandsWhenChapterUnsourced() async {+        // The default whole-title selection produces no chapter and the Site has+        // no URL rule, so the URL details are the remedy (Req 8.2 as amended).+        let (vm, _) = makeSUT()+        await vm.load()+        #expect(vm.chapterUnsourced)+        #expect(vm.disclosureState == .expanded)+    }+     @Test("Disclosure auto-expands when the Site already holds a URL rule")     func disclosureAutoExpandsForExistingURLRule() async {         let contract = Self.makeContract(currentURLRule: Self.urlRuleBasis())@@ -89,8 +118,8 @@ struct ComposedTeachingViewModelTests {     }      @Test("Collapsing retains the URL selection with a summary; only an explicit clear removes it")-    func collapseRetainsSelectionWithSummaryAndExplicitClear() async {-        let (vm, _) = makeSUT()+    func collapseRetainsSelectionWithSummaryAndExplicitClear() async throws {+        let (vm, _) = makeSUT(contract: try Self.chapterBearingContract())         await vm.load()         vm.expandDisclosure() @@ -103,8 +132,9 @@ struct ComposedTeachingViewModelTests {         #expect(vm.urlRuleDefinition != nil)         #expect(vm.urlSelectionSummary != nil) -        // Only the explicit clear removes it.-        vm.clearURLSelection()+        // Only the explicit clear removes it — the path the editor's own clear+        // button takes.+        vm.setURLRuleDefinition(nil)         #expect(vm.urlRuleDefinition == nil)         #expect(vm.urlSelectionSummary == nil)     }@@ -163,7 +193,7 @@ struct ComposedTeachingViewModelTests {         try await Task.sleep(for: .milliseconds(50))         let baseGeneration = vm.generation -        vm.toggleTitleToken(at: 0)+        vm.cycleTitleRole(at: 0)         #expect(vm.generation > baseGeneration)          try await Task.sleep(for: .milliseconds(50))@@ -201,31 +231,303 @@ struct ComposedTeachingViewModelTests {         #expect(mock.commitComposedTeachingCallCount == 0)     } -    // MARK: - Kept-span selection (Req 3.3)+    // MARK: - Inferred title rule form (Req 8.6, Decision 8) -    @Test("Whole title kept authors a whole-title rule with no trims and previews the name")-    func wholeTitleKeptAuthorsNoTrims() async {-        let (vm, _) = makeSUT(entry: Self.makeEntry(title: "Real Title"))+    @Test("The whole title marked as Work authors a whole-title rule with no trims")+    func entireTitleAuthorsWholeTitleWithoutTrims() async {+        let (vm, _) = makeSUT(entry: Self.makeEntry(title: "TtH - Story - Real Title"))         await vm.load()-        #expect(vm.titleMode == .wholeTitle)++        #expect(vm.titleChips.count == 3)+        #expect(vm.titleRoles.allSatisfy { $0 == .work })+        #expect(vm.effectiveTitleRule?.definition == .wholeTitle)         #expect(vm.trimPrefix == nil)         #expect(vm.trimSuffix == nil)-        #expect(vm.workNamePreview == "Real Title")+        #expect(vm.workNamePreview == "TtH - Story - Real Title")     } -    @Test("A narrower kept span authors the corresponding leading trim with a live preview")-    func narrowerKeptSpanAuthorsTrim() async throws {-        // Title "TtH - Story - Real Title": tokens are TtH, Story, Real, Title.+    @Test("Whole segments with Work and chapter author a segment rule")+    func wholeSegmentsWithChapterAuthorSegmentRule() async throws {         let (vm, _) = makeSUT(entry: Self.makeEntry(title: "TtH - Story - Real Title"))         await vm.load()-        try await Task.sleep(for: .milliseconds(30)) -        // Keep only the "Real" token, then extend to "Title".-        vm.toggleTitleToken(at: 2)-        vm.toggleTitleToken(at: 3)+        // Segment 0 becomes the chapter; the remaining segments stay the Work.+        vm.cycleTitleRole(at: 0) +        #expect(vm.titleRoles == [.chapter, .work, .work])+        let expected = PatternDefinition.segment(+            work: try SegmentRangeSpec(origin: .end, offset: 0, length: 2), ignored: [])+        #expect(vm.effectiveTitleRule?.definition == expected)+        #expect(vm.workNamePreview == "Story - Real Title")+        #expect(vm.chapterNamePreview == "TtH")+    }++    @Test("Whole segments with Work only author a chapter-less segment rule")+    func wholeSegmentsWorkOnlyAuthorChapterlessSegmentRule() async throws {+        let (vm, _) = makeSUT(entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()++        // Work → chapter → ignore for both boilerplate segments.+        vm.cycleTitleRole(at: 0)+        vm.cycleTitleRole(at: 0)+        vm.cycleTitleRole(at: 1)+        vm.cycleTitleRole(at: 1)++        #expect(vm.titleRoles == [.ignore, .ignore, .work])+        let expected = PatternDefinition.chapterlessSegment(+            work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: [])+        #expect(vm.effectiveTitleRule?.definition == expected)         #expect(vm.workNamePreview == "Real Title")+        #expect(vm.trimPrefix == nil)+    }++    @Test("Subdivided parts with Work only author a whole-title rule plus trims")+    func subdividedPartsWorkOnlyAuthorWholeTitleWithTrims() async throws {+        let (vm, _) = makeSUT(entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()++        vm.cycleTitleRole(at: 0)+        vm.cycleTitleRole(at: 0)+        vm.cycleTitleRole(at: 1)+        vm.cycleTitleRole(at: 1)+        // Subdivide the Work segment in place: "Real Title" → "Real", "Title".+        vm.subdivideSegment(atChip: 2)++        #expect(vm.isSubdivided)+        #expect(vm.titleChips.count == 4)+        #expect(vm.effectiveTitleRule?.definition == .wholeTitle)         #expect(vm.trimPrefix == "TtH - Story - ")         #expect(vm.trimSuffix == nil)+        #expect(vm.workNamePreview == "Real Title")++        // Dropping the trailing part narrows the kept span and the trims follow.+        // Chapter is skipped here: it would need the blank separator `.phrase`+        // rejects, so the cycle lands straight on ignore (Req 8.6).+        vm.cycleTitleRole(at: 3)+        #expect(vm.titleRoles == [.ignore, .ignore, .work, .ignore])+        #expect(vm.workNamePreview == "Real")+        #expect(vm.trimSuffix == " Title")+    }++    @Test("Subdivided parts with Work and chapter author a phrase rule")+    func subdividedPartsWithChapterAuthorPhraseRule() async throws {+        // One segment, four parts: "Real", "Title", "Ch", "3".+        let (vm, _) = makeSUT(entry: Self.makeEntry(title: "Real Title Ch 3"))+        await vm.load()+        #expect(vm.titleChips.count == 1)++        vm.subdivideSegment(atChip: 0)+        #expect(vm.titleChips.count == 4)++        vm.cycleTitleRole(at: 2)   // "Ch" leaves the Work name+        vm.cycleTitleRole(at: 3)   // "3" becomes the chapter++        #expect(vm.titleRoles == [.work, .work, .ignore, .chapter])+        #expect(vm.effectiveTitleRule?.definition+                == .phrase(prefix: "", separator: " Ch ", suffix: "", order: .workThenChapter))+        #expect(vm.workNamePreview == "Real Title")+        #expect(vm.chapterNamePreview == "3")+    }++    @Test("Two adjacent part spans are unreachable: the blank separator phrase is skipped")+    func adjacentPartSpansAreUnreachable() async throws {+        let (vm, _) = makeSUT(entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()+        vm.cycleTitleRole(at: 0)+        vm.cycleTitleRole(at: 0)+        vm.cycleTitleRole(at: 1)+        vm.cycleTitleRole(at: 1)+        vm.subdivideSegment(atChip: 2)+        #expect(vm.titleRoles == [.ignore, .ignore, .work, .work])++        // "Real" as chapter beside "Title" as Work would need a blank separator,+        // which `.phrase` rejects — the cycle skips straight past chapter to+        // ignore instead of accepting a selection that cannot author a rule.+        vm.cycleTitleRole(at: 2)++        #expect(vm.titleRoles == [.ignore, .ignore, .ignore, .work])+        #expect(vm.effectiveTitleRule?.definition == .wholeTitle)+        #expect(vm.trimPrefix == "TtH - Story - Real ")+    }++    @Test("The last Work chip keeps its role, and the refusal is explained rather than silent")+    func lastWorkChipTapIsRefusedWithAnExplanation() async throws {+        let (vm, _) = makeSUT(entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()++        vm.cycleTitleRole(at: 0)   // "TtH" becomes the chapter+        vm.cycleTitleRole(at: 2)   // "Real Title" is ignored; "Story" is the only Work+        #expect(vm.titleRoles == [.chapter, .work, .ignore])+        #expect(vm.titleSelectionNotice == nil)++        // Neither chapter nor ignore would leave anything naming the Work, so the+        // tap is refused (Req 8.6) — but the reader is told why (Req 8.4).+        vm.cycleTitleRole(at: 1)++        #expect(vm.titleRoles == [.chapter, .work, .ignore])+        #expect(vm.titleSelectionNotice == ComposedTeachingPresentation.lastWorkNotice)+    }++    @Test("The refusal explanation clears once a selection change lands")+    func selectionNoticeClearsOnTheNextAcceptedTap() async throws {+        let (vm, _) = makeSUT(entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()+        vm.cycleTitleRole(at: 0)+        vm.cycleTitleRole(at: 2)+        vm.cycleTitleRole(at: 1)+        #expect(vm.titleSelectionNotice != nil)++        // Freeing "TtH" from the chapter role is a legal change and applies.+        vm.cycleTitleRole(at: 0)++        #expect(vm.titleRoles == [.ignore, .work, .ignore])+        #expect(vm.titleSelectionNotice == nil)+    }++    @Test("Use whole segments returns the row to segment granularity")+    func useWholeSegmentsReturnsToSegments() async throws {+        let (vm, _) = makeSUT(entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()+        vm.subdivideSegment(atChip: 2)+        #expect(vm.isSubdivided)++        vm.useWholeSegments()++        #expect(!vm.isSubdivided)+        #expect(vm.titleChips.count == 3)+        #expect(vm.effectiveTitleRule?.definition == .wholeTitle)+    }++    // MARK: - Articles affordance (Req 8.7)++    @Test("Articles is not a title form: it projects and commits through its own contract")+    func articlesCommitsThroughArticlesContract() async throws {+        let (vm, mock) = makeSUT()+        await vm.load()+        try await Task.sleep(for: .milliseconds(30))++        vm.requestArticles()+        try await Task.sleep(for: .milliseconds(30))++        #expect(vm.articlesRequested)+        #expect(mock.projectArticlesCallCount >= 1)+        #expect(vm.articlesPreviewEntryCount == 0)+        #expect(!vm.canConfirm)+        #expect(vm.canConfirmArticles)++        await vm.confirmArticles()++        #expect(vm.state == .committed)+        #expect(mock.commitArticlesCallCount == 1)+        #expect(mock.commitComposedTeachingCallCount == 0)+    }++    @Test("Cancelling the Articles confirmation returns to title selection")+    func cancelArticlesReturnsToTitleSelection() async throws {+        let (vm, mock) = makeSUT()+        await vm.load()+        vm.requestArticles()+        try await Task.sleep(for: .milliseconds(30))++        vm.cancelArticles()+        try await Task.sleep(for: .milliseconds(30))++        #expect(!vm.articlesRequested)+        #expect(vm.articlesPreviewEntryCount == nil)+        #expect(mock.commitArticlesCallCount == 0)+        #expect(vm.state == .previewReady)+    }++    // MARK: - Re-teach restores the Site's retained title rule (Req 1.3)++    private static func titleRuleBasis(+        definition: PatternDefinition, trimPrefix: String? = nil, trimSuffix: String? = nil+    ) -> ComposedTitleRuleBasis {+        ComposedTitleRuleBasis(+            id: UUID(), version: 3, definition: definition,+            trimPrefix: trimPrefix, trimSuffix: trimSuffix)+    }++    private static func makeContract(+        currentTitleRule: ComposedTitleRuleBasis?, currentURLRule: ComposedURLRuleBasis? = nil+    ) -> ComposedTeachingContract {+        let basis = ComposedTeachingBasis(+            siteMode: .taught, hostname: hostname, entries: [], works: [],+            currentTitleRule: currentTitleRule, currentURLRule: currentURLRule)+        return ComposedTeachingContract(+            basis: basis, request: ComposedTeachingRequest(titleDefinition: .wholeTitle),+            outcome: makeOutcome())+    }++    @Test("Re-teaching a segment-ruled Site opens on that selection, not a fresh whole title")+    func reteachRestoresSegmentSelection() async throws {+        let work = try SegmentRangeSpec(origin: .end, offset: 0, length: 1)+        let definition = PatternDefinition.segment(work: work, ignored: [])+        let (vm, _) = makeSUT(+            contract: Self.makeContract(currentTitleRule: Self.titleRuleBasis(definition: definition)),+            entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()++        // "TtH", "Story", "Real Title" — the last segment is the Work.+        #expect(vm.titleRoles == [.chapter, .chapter, .work])+        #expect(vm.effectiveTitleRule?.definition == definition)+        #expect(!vm.isSubdivided)+    }++    @Test("Re-teaching a trimmed whole-title Site restores its kept span")+    func reteachRestoresKeptSpan() async throws {+        let (vm, _) = makeSUT(+            contract: Self.makeContract(+                currentTitleRule: Self.titleRuleBasis(+                    definition: .wholeTitle, trimPrefix: "TtH - Story - ")),+            entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()++        #expect(vm.titleRoles == [.ignore, .ignore, .work])+        #expect(vm.effectiveTitleRule?.definition == .wholeTitle)+        #expect(vm.trimPrefix == "TtH - Story - ")+        #expect(vm.workNamePreview == "Real Title")+    }++    @Test("Changing only the URL rule commits the retained title rule unchanged")+    func urlOnlyReteachPreservesTitleRule() async throws {+        let retained = try SegmentRangeSpec(origin: .end, offset: 0, length: 1)+        let definition = PatternDefinition.segment(work: retained, ignored: [])+        let (vm, mock) = makeSUT(+            contract: Self.makeContract(+                currentTitleRule: Self.titleRuleBasis(definition: definition, trimPrefix: "TtH - ")),+            entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()+        try await Task.sleep(for: .milliseconds(30))++        // Touch only the URL side, exactly as a URL-focused re-teach would.+        vm.setURLRuleDefinition(.sequence(locator: .query(name: ExactScalarString("chapter"))))+        try await Task.sleep(for: .milliseconds(30))++        let request = try #require(mock.lastProjectComposedRequest)+        #expect(request.titleDefinition == definition)+        #expect(request.trimPrefix == "TtH - ")+    }++    @Test("Editing the title after load authors a fresh rule rather than the retained one")+    func titleEditOverridesRetainedRule() async throws {+        let retained = try SegmentRangeSpec(origin: .end, offset: 0, length: 1)+        let definition = PatternDefinition.segment(work: retained, ignored: [])+        let (vm, mock) = makeSUT(+            contract: Self.makeContract(currentTitleRule: Self.titleRuleBasis(definition: definition)),+            entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()+        try await Task.sleep(for: .milliseconds(30))++        // The seeded selection is chapter, chapter, Work; dropping the first+        // segment authors a different rule from the retained one.+        vm.cycleTitleRole(at: 0)+        try await Task.sleep(for: .milliseconds(30))++        let request = try #require(mock.lastProjectComposedRequest)+        let expected = PatternDefinition.segment(+            work: retained, ignored: [try SegmentPositionSpec(origin: .start, offset: 0)])+        #expect(request.titleDefinition == expected)+        #expect(request.titleDefinition != definition)     } }
Asterism/AsterismUITests/ComposedSurfaceUITests.swift Modified +304 / -110
diff --git a/Asterism/AsterismUITests/ComposedSurfaceUITests.swift b/Asterism/AsterismUITests/ComposedSurfaceUITests.swiftindex 1585acd..a9080a8 100644--- a/Asterism/AsterismUITests/ComposedSurfaceUITests.swift+++ b/Asterism/AsterismUITests/ComposedSurfaceUITests.swift@@ -3,7 +3,13 @@ import XCTest /// Simulator UI tests for the composed teaching surface and its maintenance /// surfaces (Req 7.3): every deliverable is reached through real navigation from /// app launch. The `seeded-composed` scenario opens a V4/.m4 store with an-/// untaught actionable Entry and a composed-taught Site carrying URL identity.+/// untaught actionable Entry on `composed.test` (capture title+/// `TtH - Story - Real Title`, URL `/Story-28614-94/slug.htm`) and a+/// composed-taught Site `id.test` carrying URL identity.+///+/// The surface has no title-mode picker: the rule form is inferred from the chip+/// selection (Req 8.6, Decision 8), so these tests drive chips and subdivision+/// rather than modes. final class ComposedSurfaceUITests: XCTestCase {     let app = XCUIApplication() @@ -20,119 +26,327 @@ final class ComposedSurfaceUITests: XCTestCase {         terminateAndWaitForExit(app)     } -    // MARK: - Helpers+    // MARK: - Element helpers -    private func waitForRecentList() {-        let recentList = app.collectionViews["recent-list"]-        XCTAssertTrue(recentList.waitForExistence(timeout: 60), "Recent list should appear")+    /// Matches an identifier across element types: `LabeledContent` and chip rows+    /// surface as different types depending on how SwiftUI combines them.+    private func any(_ identifier: String) -> XCUIElement {+        app.descendants(matching: .any).matching(identifier: identifier).firstMatch     } -    /// The composed surface is present once its editor's primary control (the-    /// whole-title mode button) or its Cancel button is visible.     @discardableResult-    private func waitForComposedSurface() -> Bool {-        let mode = app.buttons["composed-mode-whole-title"]-        let cancel = app.buttons["composed-teaching-cancel"]-        return mode.waitForExistence(timeout: 20) || cancel.waitForExistence(timeout: 5)+    private func require(+        _ element: XCUIElement, _ message: String, timeout: TimeInterval = 15,+        file: StaticString = #filePath, line: UInt = #line+    ) -> XCUIElement {+        XCTAssertTrue(element.waitForExistence(timeout: timeout), message, file: file, line: line)+        return element+    }++    private func requireGone(+        _ element: XCUIElement, _ message: String, timeout: TimeInterval = 20,+        file: StaticString = #filePath, line: UInt = #line+    ) {+        let gone = expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: element)+        let result = XCTWaiter().wait(for: [gone], timeout: timeout)+        XCTAssertEqual(result, .completed, message, file: file, line: line)+    }++    private func requireEnabled(+        _ element: XCUIElement, _ message: String, timeout: TimeInterval = 20,+        file: StaticString = #filePath, line: UInt = #line+    ) {+        let enabled = expectation(for: NSPredicate(format: "isEnabled == true"), evaluatedWith: element)+        let result = XCTWaiter().wait(for: [enabled], timeout: timeout)+        XCTAssertEqual(result, .completed, message, file: file, line: line)+    }++    /// Scrolls the surface until the element exists and can be tapped, then taps+    /// it. Lazy Form rows below the fold are not instantiated until scrolled in,+    /// so existence alone is not a precondition for scrolling.+    private func scrollAndTap(+        _ element: XCUIElement, _ message: String,+        file: StaticString = #filePath, line: UInt = #line+    ) {+        _ = element.waitForExistence(timeout: 10)+        var attempts = 0+        while attempts < 8 {+            if element.exists, element.isHittable {+                element.tap()+                return+            }+            app.swipeUp()+            _ = element.waitForExistence(timeout: 2)+            attempts += 1+        }+        XCTFail(message, file: file, line: line)+    }++    /// The URL details are expanded when the editor's own controls are on screen.+    private func requireURLEditorExpanded(+        _ message: String, file: StaticString = #filePath, line: UInt = #line+    ) {+        require(app.buttons["composed-url-path-chip-0"], message, file: file, line: line)+    }++    /// Taps the URL disclosure header. SwiftUI surfaces a `DisclosureGroup`+    /// header as a button or as its label text depending on composition.+    private func toggleURLDisclosure(file: StaticString = #filePath, line: UInt = #line) {+        let candidates = [+            app.buttons["composed-url-disclosure"],+            app.staticTexts["Take the chapter from the URL"],+            app.staticTexts["Add URL details"],+            any("composed-url-disclosure"),+        ]+        for candidate in candidates where candidate.exists && candidate.isHittable {+            candidate.tap()+            return+        }+        XCTFail("The URL details disclosure should be present and tappable", file: file, line: line)+    }++    // MARK: - Navigation helpers++    private func waitForRecentList() {+        require(app.collectionViews["recent-list"], "Recent list should appear", timeout: 60)+    }++    /// The composed surface is present once its title chip row is visible.+    private func waitForComposedSurface(file: StaticString = #filePath, line: UInt = #line) {+        require(app.buttons["composed-title-chip-0"],+                "Composed teaching surface should present the title chip row",+                timeout: 20, file: file, line: line)     }      private func openComposedSurfaceViaPill() {         waitForRecentList()         let teachPill = app.buttons["teach-pill"].firstMatch-        XCTAssertTrue(teachPill.waitForExistence(timeout: 15), "Teach pill should appear for an actionable entry")+        require(teachPill, "Teach pill should appear for an actionable entry")         teachPill.tap()-        XCTAssertTrue(waitForComposedSurface(), "Composed teaching surface should appear")+        waitForComposedSurface()     } -    // MARK: - Reachability+    /// Confirms the composed commit and waits for the surface to hand off — to+    /// the post-commit Work landing-URL queue or straight back to Recent. A+    /// failed commit keeps the surface (and its Cancel button) on screen, so the+    /// disappearance is the commit signal.+    private func confirmComposedCommit(file: StaticString = #filePath, line: UInt = #line) {+        let confirm = app.buttons["composed-teaching-confirm"]+        require(confirm, "The Teach button should be available", timeout: 20, file: file, line: line)+        requireEnabled(confirm, "The Teach button should enable once the preview lands",+                       file: file, line: line)+        scrollAndTap(confirm, "The Teach button should be tappable", file: file, line: line)+        requireGone(app.buttons["composed-teaching-cancel"],+                    "A successful commit leaves the composed surface", file: file, line: line)+        dismissPostTeachingQueueIfPresent()+    }++    private func dismissPostTeachingQueueIfPresent() {+        let done = app.buttons["post-teaching-done"]+        if done.waitForExistence(timeout: 3), done.isHittable {+            done.tap()+        }+    }++    // MARK: - Reachability (Req 7.3)      func testRecentPillOpensComposedSurface() {         openComposedSurfaceViaPill()-        XCTAssertTrue(app.buttons["composed-mode-whole-title"].waitForExistence(timeout: 10),-                      "Composed surface presents the whole-title mode as primary")+        // Title teaching is the primary content: chips plus the live name preview.+        require(app.buttons["composed-title-chip-1"], "Every title segment is a chip")+        require(app.buttons["composed-title-chip-2"], "Every title segment is a chip")+        require(any("composed-work-name-preview"), "The live Work-name preview is shown")+        XCTAssertFalse(app.buttons["composed-teaching-cancel"].label.isEmpty,+                       "The surface keeps a Cancel affordance")     }      func testEntryDetailTeachOpensComposedSurface() {         waitForRecentList()         let entry = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'recent-entry-'")).firstMatch-        XCTAssertTrue(entry.waitForExistence(timeout: 15), "A recent entry row should exist")+        require(entry, "A recent entry row should exist")         entry.tap()          let teachButton = app.buttons["entry-detail-teach-button"]         let reteachButton = app.buttons["entry-detail-reteach-button"]-        _ = teachButton.waitForExistence(timeout: 15) || reteachButton.waitForExistence(timeout: 5)+        let appeared = teachButton.waitForExistence(timeout: 15)+            || reteachButton.waitForExistence(timeout: 5)+        XCTAssertTrue(appeared, "Entry detail should offer Teach/Re-teach")         let target = teachButton.exists ? teachButton : reteachButton-        XCTAssertTrue(target.exists, "Entry detail should offer Teach/Re-teach")         target.tap()-        XCTAssertTrue(waitForComposedSurface(), "Entry-detail Teach opens the composed surface")+        waitForComposedSurface()+    }++    // MARK: - Inferred forms (Req 8.6)++    /// Whole segments with a chapter marked → `.segment`, committed.+    func testSegmentSelectionCommitsSegmentRule() {+        openComposedSurfaceViaPill()+        // Default: every segment names the Work, so nothing sources a chapter.+        XCTAssertFalse(app.staticTexts["TtH"].exists && any("composed-chapter-name-preview").exists,+                       "The default whole-title selection produces no chapter")++        // One tap moves the leading segment to the chapter; the remaining+        // segments stay the Work, which is a positional `.segment` rule.+        app.buttons["composed-title-chip-0"].tap()++        require(any("composed-chapter-name-preview"),+                "Marking a segment as the chapter previews the chapter live")+        require(app.staticTexts["Story - Real Title"], "The Work name preview follows the selection")+        require(app.staticTexts["TtH"], "The chapter preview follows the selection")++        confirmComposedCommit()+        waitForRecentList()+        requireGone(app.buttons["teach-pill"],+                    "A committed segment rule settles the seeded Entry, retiring its Teach pill")+    }++    /// Subdividing a segment in place and marking a chapter → `.phrase`, committed.+    func testSubdivideInPlaceCommitsPhraseRule() {+        openComposedSurfaceViaPill()++        // "Real Title" is the only multi-part segment; its scissors control+        // subdivides it in place — no sheet, no second screen (Req 8.3).+        XCTAssertFalse(app.buttons["composed-title-chip-3"].exists,+                       "The row starts at segment granularity")+        scrollAndTap(app.buttons["composed-title-split-2"],+                     "A selected multi-part segment offers the in-place split control")+        require(app.buttons["composed-title-chip-3"], "Subdivision adds part chips to the same row")+        require(app.buttons["composed-title-use-segments"],+                "Subdivision offers a way back to whole segments")++        // Drop the leading boilerplate, then make "Story" the chapter. With the+        // Work coming from parts, that is an exact-literal phrase rule.+        app.buttons["composed-title-chip-0"].tap()+        app.buttons["composed-title-chip-1"].tap()++        require(app.staticTexts["Real Title"], "The Work name preview follows the part selection")+        require(app.staticTexts["Story"], "The chapter preview follows the part selection")++        confirmComposedCommit()+        waitForRecentList()+        requireGone(app.buttons["teach-pill"],+                    "A committed phrase rule settles the seeded Entry, retiring its Teach pill")+    }++    /// Whole segments with no chapter marked → `.chapterlessSegment`, which leaves+    /// chapters unsettled and must be acknowledged per commit (Req 2.1).+    func testWorkOnlySelectionRequiresUnsettledAcknowledgment() {+        openComposedSurfaceViaPill()++        // Work → chapter → ignore for both boilerplate segments.+        app.buttons["composed-title-chip-0"].tap()+        app.buttons["composed-title-chip-0"].tap()+        app.buttons["composed-title-chip-1"].tap()+        app.buttons["composed-title-chip-1"].tap()++        require(app.staticTexts["Real Title"], "The Work name comes from the remaining segment")+        XCTAssertFalse(any("composed-chapter-name-preview").exists,+                       "A Work-only selection sources no chapter")++        let confirm = app.buttons["composed-teaching-confirm"]+        require(confirm, "The Teach button should be available", timeout: 20)+        requireEnabled(confirm, "The Teach button should enable once the preview lands")+        scrollAndTap(confirm, "The Teach button should be tappable")++        // A chapter-less rule with no URL sequence is refused until acknowledged.+        require(any("composed-acknowledgment"),+                "Confirming surfaces the unsettled-chapters acknowledgment")+        scrollAndTap(app.buttons["composed-acknowledge-confirm"],+                     "The acknowledgment offers teaching anyway")+        requireGone(app.buttons["composed-teaching-cancel"],+                    "Acknowledging commits and leaves the composed surface")+        dismissPostTeachingQueueIfPresent()     } -    // MARK: - Kept-span title selector (Req 3.3)+    // MARK: - URL details disclosure (Req 8.2, 8.4) -    func testKeptSpanLivePreviewAndTrimmedRule() {+    /// The default whole-title selection sources no chapter, so the URL details+    /// open automatically and name themselves as the remedy.+    func testChapterUnsourcedAutoExpandsURLDetailsAsTheRemedy() {         openComposedSurfaceViaPill()-        XCTAssertTrue(app.buttons["composed-title-token-2"].waitForExistence(timeout: 10),-                      "The kept-span token row should be visible")-        // Whole title kept by default authors no trims → no reset control.-        XCTAssertFalse(app.buttons["composed-title-reset"].exists,-                       "Keeping the whole title authors no trims")-        // Trim to a narrower span; a trim is authored, proven live by the reset-        // control appearing (Req 3.3 live rule change).-        app.buttons["composed-title-token-2"].tap()-        XCTAssertTrue(app.buttons["composed-title-reset"].waitForExistence(timeout: 10),-                      "A narrower kept span authors a trim and offers keeping the whole title")++        requireURLEditorExpanded(+            "Unsourced chapters expand the URL details in place as the remedy")+        require(app.staticTexts["Take the chapter from the URL"],+                "The disclosure names itself as the chapter remedy")++        // Sourcing the chapter from the title retires the remedy framing.+        app.buttons["composed-title-chip-0"].tap()+        requireGone(app.staticTexts["Take the chapter from the URL"],+                    "A title that sources the chapter no longer needs the URL remedy")+        require(app.staticTexts["Add URL details"],+                "The disclosure returns to its benefit label once the chapter is sourced")     } -    // MARK: - URL disclosure (Req 8.2, 8.4)+    /// Regression: the split editor used to destroy its own state. Every local+    /// edit round-tripped through the view model and came back as a changed+    /// definition, which the editor could not distinguish from an external+    /// change, so it re-seeded and cleared `splitSelection` — closing the editor+    /// on the first token tap and making the within-component split unusable.+    func testWithinComponentSplitSurvivesTokenEdits() {+        openComposedSurfaceViaPill()+        requireURLEditorExpanded("The URL details are expanded for the unsourced chapter") -    func testDisclosureCollapsedByDefaultAndExpands() {+        // `/Story-28614-94/` is the multi-part component the split editor exists for.+        scrollAndTap(app.buttons["composed-url-path-chip-0"],+                     "The example URL's path components are selectable chips")+        scrollAndTap(app.buttons["composed-url-split-button"],+                     "A selected multi-part component offers the within-component split")++        require(any("composed-url-split-editor"),+                "The split editor opens and stays open after being invoked")++        // The edit that used to collapse it.+        scrollAndTap(app.buttons["composed-url-split-token-1"],+                     "The split editor's tokens are individually selectable")+        require(any("composed-url-split-editor"),+                "Adjusting a split token leaves the split editor open")+        require(app.buttons["composed-url-unsplit-button"],+                "The split remains adjustable rather than collapsing after one edit")+    }++    /// Collapsing is presentation-only: the selection survives with a summary (Q11).+    func testCollapsedDisclosureRetainsURLSelectionSummary() {         openComposedSurfaceViaPill()-        // Collapsed by default: the URL editor is not shown until expanded.-        XCTAssertFalse(app.otherElements["composed-url-editor"].exists,-                       "URL details are collapsed by default")-        let disclosure = app.buttons["composed-url-disclosure"].firstMatch-        let disclosureText = app.staticTexts["Add URL details"].firstMatch-        if disclosure.waitForExistence(timeout: 10) {-            disclosure.tap()-        } else if disclosureText.waitForExistence(timeout: 5) {-            disclosureText.tap()-        } else {-            XCTFail("The URL details disclosure should be present")-        }-        XCTAssertTrue(app.otherElements["composed-url-editor"].waitForExistence(timeout: 10)-                        || app.buttons["composed-url-slot-picker"].waitForExistence(timeout: 5)-                        || app.staticTexts["Example URL"].waitForExistence(timeout: 5),-                      "Tapping the disclosure expands the URL editor in place")+        requireURLEditorExpanded("The URL details are expanded for the unsourced chapter")++        scrollAndTap(app.buttons["composed-url-path-chip-0"],+                     "The example URL's path components are selectable chips")+        require(app.buttons["composed-url-clear"], "A URL selection offers an explicit clear")++        toggleURLDisclosure()+        requireGone(app.buttons["composed-url-path-chip-0"], "Collapsing hides the URL editor")+        require(any("composed-url-collapsed-summary"),+                "A collapsed disclosure summarises the retained URL selection")     } -    // MARK: - Unsettled-chapters acknowledgment (Req 2.1, Q3)+    // MARK: - Articles affordance (Req 8.7) -    func testUnsettledChaptersAcknowledgment() {+    /// Articles is not a title form: it has its own affordance, its own+    /// confirmation of consequences, and its own commit.+    func testArticlesAffordanceIsSeparateFromTitleSelection() {         openComposedSurfaceViaPill()-        let confirm = app.buttons["composed-teaching-confirm"]-        XCTAssertTrue(confirm.waitForExistence(timeout: 20), "The Teach button should be available")-        let enabled = NSPredicate(format: "isEnabled == true")-        expectation(for: enabled, evaluatedWith: confirm)-        waitForExpectations(timeout: 20)-        // Bring the confirm button fully on-screen before tapping.-        app.swipeUp()-        confirm.tap()-        // Reaching the interstitial through real navigation is the Req 2.1/7.3-        // deliverable: a chapter-less rule with no URL sequence is refused until-        // acknowledged, not silently committed.-        let ackButton = app.buttons["composed-acknowledge-confirm"]-        let ackTanyway = app.buttons["Teach anyway"]-        let ackText = app.staticTexts["Chapters will stay unsettled"]-        let surfaced = ackButton.waitForExistence(timeout: 10)-            || ackTanyway.waitForExistence(timeout: 2)-            || ackText.waitForExistence(timeout: 2)-        XCTAssertTrue(surfaced,-                      "Confirming a chapter-less rule surfaces the unsettled-chapters acknowledgment")-        // Best-effort follow-through: acknowledge and commit.-        let commitButton = ackButton.exists ? ackButton : ackTanyway-        if commitButton.exists && commitButton.isHittable {-            commitButton.tap()-        }++        scrollAndTap(app.buttons["composed-articles-affordance"],+                     "Articles has its own affordance, separated from title selection")+        require(any("composed-articles-confirmation"),+                "Articles states its consequences before committing")+        require(app.buttons["composed-articles-confirm"], "Articles carries its own commit button")+        XCTAssertFalse(app.buttons["composed-title-chip-0"].exists,+                       "The Articles confirmation replaces title selection rather than sitting inside it")++        // Backing out returns to title selection untouched.+        scrollAndTap(app.buttons["composed-articles-cancel"], "The confirmation can be declined")+        require(app.buttons["composed-title-chip-0"], "Declining returns to title selection")++        // Committing goes through the articles contract, not the composed one.+        scrollAndTap(app.buttons["composed-articles-affordance"], "Articles can be reopened")+        let confirm = app.buttons["composed-articles-confirm"]+        require(confirm, "Articles carries its own commit button")+        requireEnabled(confirm, "The Articles commit enables once its preview lands")+        scrollAndTap(confirm, "The Articles commit should be tappable")+        requireGone(app.buttons["composed-teaching-cancel"],+                    "Committing Articles leaves the composed surface")     }      // MARK: - Work detail → Review URL identity → Recalculate (Req 7.1, 7.2)@@ -140,48 +354,28 @@ final class ComposedSurfaceUITests: XCTestCase {     func testWorkDetailReviewAndRecalculate() {         waitForRecentList()         let worksTab = app.tabBars.buttons["Works"]-        XCTAssertTrue(worksTab.waitForExistence(timeout: 15), "The Works tab should exist")+        require(worksTab, "The Works tab should exist")         worksTab.tap()          let work = app.buttons.matching(NSPredicate(format: "label CONTAINS 'Real Work'")).firstMatch-        XCTAssertTrue(work.waitForExistence(timeout: 20), "The seeded taught Work should appear in Works")+        require(work, "The seeded taught Work should appear in Works", timeout: 20)         work.tap()          // Work detail is a Form (lazy List); scroll the URL Identity section in.-        let reviewButton = app.buttons["work-detail-review-url-identity"]-        var reviewVisible = reviewButton.waitForExistence(timeout: 10)-        var scrolls = 0-        while !reviewVisible && scrolls < 5 {-            app.swipeUp()-            reviewVisible = reviewButton.waitForExistence(timeout: 3)-            scrolls += 1-        }-        XCTAssertTrue(reviewVisible,-                      "Work detail presents Review URL identity for a Work with URL identity")-        reviewButton.tap()+        scrollAndTap(app.buttons["work-detail-review-url-identity"],+                     "Work detail presents Review URL identity for a Work with URL identity") -        let recalcButton = app.buttons["review-recalculate-button"]-        XCTAssertTrue(recalcButton.waitForExistence(timeout: 15),-                      "The review offers Recalculate from the split/conflict states")-        recalcButton.tap()+        scrollAndTap(app.buttons["review-recalculate-button"],+                     "The review offers Recalculate from the split/conflict states")          // Reaching the recalculation preview/confirm surface through real-        // navigation (Work detail → Review URL identity → Recalculate) is the-        // Req 7.1/7.3 deliverable.-        let confirm = app.buttons["recalculate-confirm"]-        XCTAssertTrue(confirm.waitForExistence(timeout: 15),-                      "The recalculation preview/confirm surface should appear")-        // Best-effort follow-through: commit the recalculation.-        var confirmScrolls = 0-        while !confirm.isHittable && confirmScrolls < 4 {-            app.swipeUp()-            confirmScrolls += 1-        }-        if confirm.isHittable {-            confirm.tap()-            let committedDone = app.buttons["recalculate-committed-done"]-            let noChangesDone = app.buttons["recalculate-no-changes-done"]-            _ = committedDone.waitForExistence(timeout: 10) || noChangesDone.waitForExistence(timeout: 2)-        }+        // navigation is the Req 7.1/7.3 deliverable.+        scrollAndTap(app.buttons["recalculate-confirm"],+                     "The recalculation preview/confirm surface should appear")+        let committed = any("recalculate-committed")+        let noChanges = any("recalculate-no-changes")+        let settled = committed.waitForExistence(timeout: 20)+            || noChanges.waitForExistence(timeout: 5)+        XCTAssertTrue(settled, "Recalculation reports either a commit or no changes")     } }
CHANGELOG.md Modified +30 / -6
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 590b529..b4f6248 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,8 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +### Upgrading++- **Your library migrates to schema V4 the first time you open the app after+  this update.** The migration is crash-safe and needs no action from you: it+  runs once, verifies itself, and marks the library ready.+- **Open the app once before sharing to it.** The share extension deliberately+  refuses to capture until the app has completed the migration, so a share+  attempted before the first launch will fail closed rather than write to a+  half-migrated library.+- **Backups now export as format `4/4`.** Older builds cannot read a `4/4`+  file. Existing `2/2` and `3/3` backups still import.+- **Teaching is now one screen.** A Site no longer has an either/or "ordinary"+  or "Work-only" mode; title and URL knowledge compose, and each derived field+  comes from whichever rule supplies it. Existing taught Sites carry over+  without re-teaching.+ ### Fixed +- Fixed the unsettled-chapters acknowledgment's confirm button being unreachable to VoiceOver and to UI tests: an accessibility identifier on the container without a matching containment trait collapsed the subtree and hid the controls inside it.+- Fixed re-teaching silently replacing the rules it was opened on. The composed teaching surface never read the Site's retained title rule, so it always opened on a fresh whole-title selection and confirming a URL-only change replaced the real title rule and cleared pattern-derived chapters; the URL details editor likewise kept chip selections that were never seeded from the retained definition, so a single tap rebuilt the rule from empty state and narrowed a retained identity-and-sequence rule to one field. The surface now seeds both editors from the basis (kept span from the stored trims, segment roles inverted from the stored anchors) and commits an untouched title rule verbatim, so the canonicalizing comparator leaves its version and provenance alone. A second, redundant "Clear URL details" button that cleared the definition without resetting the editor was removed.+- Fixed two fail-open error paths that reported success without doing the work: a failed prospective-Work plan shipped an empty list while the entry assignments still requested `.create`, so the commit silently skipped every Work and still returned `.committed`; and a failed fetch in the recalculation change detector made every comparison loop skip, returning `.noChanges` with zero writes and no error. Both now propagate to the typed invalidated outcome.+- Fixed capture and teaching disagreeing on Work identity for canonically equivalent titles: capture matched Work titles with Swift's `String ==` (Unicode canonical equivalence) while the teaching sweep matches through `ExactScalarString`, so a Work whose title differed only by NFC/NFD composition was reused at capture but created fresh at teaching. Capture now uses exact-scalar comparison on both title paths.+- Fixed Articles mode being offered but not committable: the mode picker and its stepper were live, but the composed request builder returned nil for articles, so confirmation was permanently disabled and `projectArticles`/`commitArticles` had no caller. Articles now projects and commits through its own repository pair, as the one-way Site transition it is, with its own preview row.+- Fixed the composed teaching surface bypassing the capability gate: Entry detail, the Recent pills, and Work detail each guarded on their injected `AsterismCapabilities` and then passed a hardcoded `.m4`, so pre-M4 gates could author whole-title, trim, and sequence rules in the UI and only fail at the repository. All three now pass their injected value.+- Fixed avoidable work on the launch, capture, and preview hot paths: capture commit fetched every Work in the library and filtered in memory, the open-path validator decoded each URL rule's JSON definition twice per Entry, and the composed preview resolved Work evidence by linear scan once per Work. Removed a dead `phraseSelectionFromSegmentRoles` stub that always returned nil, two byte-identical copies of the token-span helpers, and an unnecessary `@unchecked Sendable`; `AsterismCore` now builds warning-free. - Fixed the URL teaching, post-teaching Work URL confirmation, and Work Merge flows being unreachable: the views existed but nothing presented them. Entry detail now offers URL teaching (initial or replacement), title teaching hands off into URL teaching on untaught Sites (including the Work-only route from Recent's inline Teach), Work detail gains Merge into…, and an end-to-end UI journey test drives the flow through real navigation. - Fixed committed Work-only teaching appearing to corrupt or delete the library: the Recent presentation and Entry detail rejected the legal pattern-less taught Site shape, and the share extension's Save silently disabled because capture projection threw for the same shape. All read and capture surfaces now accept Work-only Sites, and Work-only captures save conservatively until a teaching sweep applies the rule. - Fixed URL teaching and recalculation commits persisting invalid states: commits now validate the complete graph before their single save and roll back to a typed invalidated outcome. The backstop exposed and led to fixes for stale V2 identity keys left on conservative entries, sequence-less rules erasing their own extraction evidence while marking entries identity-assigned, and ordinary URL teaching being projectable for Sites with no title pattern (now refused at projection with a typed reason; replacement likewise requires a current rule).@@ -36,15 +59,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Added +- Redesigned the Unified Teaching Composition teaching surface so the title rule form is inferred from what the reader selected rather than chosen from a mode picker (Decision 8). The four-button picker (Whole title / Segments / Phrase / Articles) is gone: the example title renders as its delimiter-split segments, tapping a segment says what it supplies, and an already-selected multi-part segment offers an inline control that splits it into its parts in the same chip row — no sheet and no second screen. Whole segments author a segment rule (chapter-less when only the Work is marked); subdivided parts author a phrase rule, or a whole-title rule with trims when only the Work is marked. Selections that cannot author a legal rule are unreachable rather than rejected after the fact: a tap skips to the next role that works, and when no role does — demoting the last remaining Work part — the surface explains why instead of silently swallowing the tap. The character-level boundary steppers are removed; a boundary inside an alphanumeric run is no longer expressible from the title, and the URL sequence source is the remedy, which the same flow now surfaces automatically whenever the title leaves the chapter unsourced. Articles is reclassified as the one-way Site transition it always was rather than a title form, keeping its own separated affordance and confirmation. No schema, capability-gate, validator, or backup-format change: phrase rules already express arbitrary literals around and between two fields, so subdivided selection derives an existing rule form. - Finalized the Unified Teaching Composition schema to V4 and hardened the milestone. The runtime now opens on schema V4: the live model classes are frozen as V4's (nested under `AsterismSchemaV4`, reached by top-level typealiases) while the pre-M4 shape is a frozen `AsterismSchemaV3` snapshot for the `[V3,V4]` migration plan, so `Site` drops the superseded `titleInterpretation` and `workTitleTrimRule` columns and every reader resolves naming and trims from the Site's active title rule instead. `V3LibraryValidator`, `SiteTitleInterpretation`, `WorkOnlyTitleCleaner`, and the M3 URL-teaching subsystem are removed; the frozen 2/2 and 3/3 backup formats keep byte-identical wire output through self-contained copies, and the V4 import/export path (import dispatch across 2/2, 3/3, and native 4/4) becomes primary. The app and share extension open via `openV4`, so the composed teaching surface commits against the real V4 runtime, with per-Site quarantine wired from the open-path validator. An opt-in `M4PerformanceFixture` (5,000 composed Entries) asserts the preview, capture, and extension-open p95 budgets, and the M3 pinned behavior suites are carried forward against the composed runtime.-- Added the Unified Teaching Composition teaching and capture UI: one composed teaching surface replaces M3's separate title and URL teaching flows. A kept-span title selector (token chips with first/last kept-token selection plus character-level boundary controls) drives a live "Work will be named" preview, with URL details behind an inline disclosure that auto-expands when the Site already has a URL rule or a field is left unsourced and retains its selections when collapsed; one preview, one commit, with a per-commit unsettled-chapters acknowledgment. `TeachingViewModel` evolves into `ComposedTeachingViewModel` (absorbing the URL teaching view model); `URLTeachingView`, its view model, and the coordinator's teaching phase are deleted, and the Entry-detail, Recent-pill, and Work-detail entry points now open the composed surface. Work-detail gains Review URL identity and a Recalculate flow with the preview/confirm contract. The share extension is rewired lookup-first: it runs `captureLookup` with the payload title, routes edit/ambiguous to the existing re-share states, and hands a new capture off to the shipped capture stack with the pre-insert race guard active. Simulator UI tests drive every reachable surface through real navigation from launch. The composed surface runs against the V4 runtime, which the schema finalization (task 25) switches on in production.-- Added the Unified Teaching Composition backup format 4/4: `BackupV4Types`/`BackupV4Codec` with a root-strict envelope and a reference validator enforcing the closed tuple and Entry-state tables, V4-only export that decode-validates its own bytes and refuses under quarantine with a Site-naming error, and a `V3ToV4BackupMapper` plus version dispatch that imports `2/2`, `3/3`, and native `4/4` payloads (the `2/2` path chaining both mappers) while preserving provenance references and conservative-key aliases. The capability gate advances to `.m4`, with the frozen V3 codec pinned to its historical `"m3"` gate so every earlier backup stays byte-identical and importable. The V4 format coexists with the still-V3 runtime; task 25 promotes it to the primary import/export path when it switches the runtime to V4.-- Added the Unified Teaching Composition repository operations — the Core runtime that composes title and URL sources into one derivation. A single `ComposedTeachingProjectionPlanner` merges title and URL teaching into one `ComposedTeachingOutcome` (per-Entry before/after, per-Work projections, issues, prospective Works, settlement); `commitComposedTeaching` commits it under the exclusive lock with the refetch → re-project → prospective-graph-validate → single-save discipline, rolling back to a typed invalidated outcome on any validation failure and treating a semantically unchanged rule as a true no-op (compared through the canonicalizing comparator, never a version bump). Recalculation is a distinct pair (`previewRecalculation` / `commitRecalculation`) that reapplies the unchanged current rules, re-derives every Entry value, and reports no-changes when nothing differs — deliberately exempt from no-op suppression. Load-time validation now scopes failures per Site into an in-memory quarantine map (never persisted): projections over a quarantined Site refuse with typed reasons and capture takes the conservative path, while composed re-teaching stays permitted and clears the quarantine on a valid commit. Capture applies the Site's rules at commit time through the shared derivation engine, populating each Entry's conservative-key alias and writing v2/v3/conservative identity per the Entry-state enumeration; capture lookup gains title-aware v3 candidates and matches against both the identity key and the conservative alias, with a pre-insert re-lookup under the lock converting the lookup-to-commit race into an edit/ambiguous disposition instead of a duplicate. These paths produce V4-shaped graphs (one active title rule per taught Site, conservative key equal to the raw URL) that the V4 validator accepts — the precondition the schema finalization (task 25) depends on.-- Added the Unified Teaching Composition V3→V4 migration and bootstrap: schema version 4 with a `[V3,V4]` plan, a durable migration sidecar (atomic temp-file + fsync + rename write with an embedded SHA-256 self-checksum, so a torn or corrupt sidecar is always detected and never silently consumed), and an app-only bootstrap that orchestrates the cross-stage transform under the exclusive lock — writing the sidecar before conversion, then running an idempotent completion pass that creates whole-title rules for migrated Work-only Sites and backfills every Entry's conservative-key alias, validating the graph via the V4 validator before publishing the `AsterismV4.ready` marker and deleting the V3 marker. The transform is deliberately not a SwiftData custom stage (which would also run inside the share extension, which must never migrate); the extension fails closed until the V4 marker exists. The full bootstrap state table — torn sidecar, mid-completion resume, both-markers, unverifiable partial migration — is covered, with unverifiable states failing loudly rather than certifying. The schema *finalization* (physically dropping the two superseded Site columns, freezing the V3 snapshot, and deleting the V3 validator) is sequenced after the Core runtime rewrite that replaces those columns with title rules (task 25, per Decision 7), so no phase carries pointless dead schema.-- Built the Unified Teaching Composition foundation layer: the additive V4 model fields plus the pure derivation and validation core the later phases compose. Title rules gain whole-title and chapter-less forms with fail-open affix trims (never throwing, falling back to the untrimmed title exactly); URL rules gain a sequence-only extraction arm with a canonical sequence+name (v3) identity key codec mirroring the v2 discipline; a canonicalizing comparator treats semantically equal rule definitions as unchanged so no-op teaching never re-versions; an `.m4` capability gate rejects every new form below M4 so frozen M2/M3 validation is untouched; `ComposedDeriver` is the single source of truth for what a capture title and raw URL yield under a Site's rules; and `V4LibraryValidator` enforces the closed tuple table and Entry-state enumeration with per-Site diagnoses instead of throw-on-first. The V3→V4 model-snapshot freeze and the runtime capability flip to `.m4` are deferred to later phases per Decision 6 and the current-gate note, keeping V3 lightweight-migratable in the meantime.+- Added the Unified Teaching Composition teaching and capture UI: one composed teaching surface replaces M3's separate title and URL teaching flows. URL details sit behind an inline disclosure that auto-expands when the Site already has a URL rule or a field is left unsourced and retains its selections when collapsed; one preview, one commit, with a per-commit unsettled-chapters acknowledgment. `TeachingViewModel` evolves into `ComposedTeachingViewModel` (absorbing the URL teaching view model); `URLTeachingView`, its view model, and the coordinator's teaching phase are deleted, and the Entry-detail, Recent-pill, and Work-detail entry points now open the composed surface. Work-detail gains Review URL identity and a Recalculate flow with the preview/confirm contract. The share extension is rewired lookup-first: it runs `captureLookup` with the payload title, routes edit/ambiguous to the existing re-share states, and hands a new capture off to the shipped capture stack with the pre-insert race guard active. Simulator UI tests drive every reachable surface through real navigation from launch. The composed surface runs against the V4 runtime.+- Added the Unified Teaching Composition backup format 4/4: `BackupV4Types`/`BackupV4Codec` with a root-strict envelope and a reference validator enforcing the closed tuple and Entry-state tables, V4-only export that decode-validates its own bytes and refuses under quarantine with a Site-naming error, and a `V3ToV4BackupMapper` plus version dispatch that imports `2/2`, `3/3`, and native `4/4` payloads (the `2/2` path chaining both mappers) while preserving provenance references and conservative-key aliases. The capability gate advances to `.m4`, with the frozen V3 codec pinned to its historical `"m3"` gate so every earlier backup stays byte-identical and importable.+- Added the Unified Teaching Composition repository operations — the Core runtime that composes title and URL sources into one derivation. A single `ComposedTeachingProjectionPlanner` merges title and URL teaching into one `ComposedTeachingOutcome` (per-Entry before/after, per-Work projections, issues, prospective Works, settlement); `commitComposedTeaching` commits it under the exclusive lock with the refetch → re-project → prospective-graph-validate → single-save discipline, rolling back to a typed invalidated outcome on any validation failure and treating a semantically unchanged rule as a true no-op (compared through the canonicalizing comparator, never a version bump). Recalculation is a distinct pair (`previewRecalculation` / `commitRecalculation`) that reapplies the unchanged current rules, re-derives every Entry value, and reports no-changes when nothing differs — deliberately exempt from no-op suppression. Load-time validation now scopes failures per Site into an in-memory quarantine map (never persisted): projections over a quarantined Site refuse with typed reasons and capture takes the conservative path, while composed re-teaching stays permitted and clears the quarantine on a valid commit. Capture applies the Site's rules at commit time through the shared derivation engine, populating each Entry's conservative-key alias and writing v2/v3/conservative identity per the Entry-state enumeration; capture lookup gains title-aware v3 candidates and matches against both the identity key and the conservative alias, with a pre-insert re-lookup under the lock converting the lookup-to-commit race into an edit/ambiguous disposition instead of a duplicate. These paths produce V4-shaped graphs (one active title rule per taught Site, conservative key equal to the raw URL) that the V4 validator accepts.+- Added the Unified Teaching Composition V3→V4 migration and bootstrap: schema version 4 with a `[V3,V4]` plan, a durable migration sidecar (atomic temp-file + fsync + rename write with an embedded SHA-256 self-checksum, so a torn or corrupt sidecar is always detected and never silently consumed), and an app-only bootstrap that orchestrates the cross-stage transform under the exclusive lock — writing the sidecar before conversion, then running an idempotent completion pass that creates whole-title rules for migrated Work-only Sites and backfills every Entry's conservative-key alias, validating the graph via the V4 validator before publishing the `AsterismV4.ready` marker and deleting the V3 marker. The transform is deliberately not a SwiftData custom stage (which would also run inside the share extension, which must never migrate); the extension fails closed until the V4 marker exists. The full bootstrap state table — torn sidecar, mid-completion resume, both-markers, unverifiable partial migration — is covered, with unverifiable states failing loudly rather than certifying. The schema finalization — physically dropping the two superseded Site columns, freezing the V3 snapshot, and deleting the V3 validator — landed after the Core runtime rewrite that replaces those columns with title rules (Decision 7), so no phase carried dead schema.+- Built the Unified Teaching Composition foundation layer: the additive V4 model fields plus the pure derivation and validation core the later phases compose. Title rules gain whole-title and chapter-less forms with fail-open affix trims (never throwing, falling back to the untrimmed title exactly); URL rules gain a sequence-only extraction arm with a canonical sequence+name (v3) identity key codec mirroring the v2 discipline; a canonicalizing comparator treats semantically equal rule definitions as unchanged so no-op teaching never re-versions; an `.m4` capability gate rejects every new form below M4 so frozen M2/M3 validation is untouched; `ComposedDeriver` is the single source of truth for what a capture title and raw URL yield under a Site's rules; and `V4LibraryValidator` enforces the closed tuple table and Entry-state enumeration with per-Site diagnoses instead of throw-on-first. The V3→V4 model-snapshot freeze and the runtime capability flip to `.m4` landed later in the milestone per Decision 6, keeping V3 lightweight-migratable in the meantime. - Added the Unified Teaching Composition milestone spec (requirements, design, decision log, and a 24-task dependency-linked plan across foundation, migration, repository, backup, teaching UI, and hardening streams). It replaces M3's site-level title/URL interpretation fork with per-field teaching source composition: a Site holds at most one title rule and one URL rule, and each derived field — Work name, Work identity, chapter — resolves from whichever rule supplies it, removing the ordinary/Work-only mode split and its forbidden transitions. - Specified schema V4 (dropping `titleInterpretation` and `workTitleTrimRule`, adding whole-title and chapter-less title-rule forms, trim affixes, and a sequence-only URL-rule form) with a crash-safe three-step migration, and closed M3's capture gap by applying URL rules at commit time so newly shared chapters receive identity, sequence, and assignment without a teaching sweep.-- Added the URL rule authoring surface: whole-component path and query chips, a combined-template split editor with token chips, character-level boundary controls, an auto-derived prefill that widens exact prefix context until the template round-trips (handling `Story-28614-94`-style components), and reader-actionable guidance for ambiguous separators.+- Added the URL rule authoring surface: whole-component path and query chips, a combined-template split editor with token chips, an auto-derived prefill that widens exact prefix context until the template round-trips (handling `Story-28614-94`-style components), and reader-actionable guidance for ambiguous separators. - Added a Work-title trim rule for Work-only Sites: exact affixes selected from the example title's tokens name every Work from the trimmed capture title, persist on the Site, travel through Backup V3, rename identity-matched parsed-title Works on re-teach, and apply to entry and Recent presentation while the raw capture title remains provenance evidence. - Added atomic same-Site Work Merge commits and an accessible picker/preview/confirmation flow that disclose retained, discarded, and audited consequences, save once, roll back on failure, and require review after stale refreshes. - Added generation-owned URL teaching with bracket authoring, conflict/Recent/Entry-detail presentation, and a post-teaching Work URL confirmation queue with independent confirm, skip, stale, and failure recovery.
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV4.swift Modified +11 / -11
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV4.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV4.swiftindex 4b087fc..1f82b3a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV4.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV4.swift@@ -1,17 +1,17 @@ import Foundation import SwiftData -/// The M4 runtime schema (Decision 6). The live model classes are V4's; the-/// pre-M4 V3 shape is referenced by `AsterismSchemaV3` for the migration plan's-/// `from` version. Task 10.2 keeps the live classes shared between both-/// versioned schemas: the physical drop of `Site.titleInterpretationRaw` /-/// `workTitleTrimRule` and the frozen nested-snapshot freeze are inseparable-/// from rewriting the ~15 call sites that still read those columns (the M3-/// URL-identity, teaching, recent-presentation, and backup paths owned by later-/// phases), so — exactly as Decision 6 deferred Q22's freeze out of task 1 —-/// that drop defers to the phase that rewrites those readers. What lands here is-/// the real thing migration needs: a version-4 schema, the custom stage that-/// writes the durable sidecar, and the post-open completion pass.+/// The M4 runtime schema (Decision 6) — the schema the app and the share+/// extension actually open.+///+/// The live model classes are V4's: `Site` no longer carries+/// `titleInterpretationRaw` or `workTitleTrimRule`, and every reader resolves+/// naming and trims from the Site's active title rule instead. The pre-M4 shape+/// survives only as the frozen nested snapshots in `AsterismSchemaV3`, which+/// exist to give the migration plan a `from` version. Freezing V3 and dropping+/// the two columns landed together (Decision 6): the freeze is not implementable+/// while the live classes are still V3's, and the drop is inseparable from+/// rewriting the readers of those columns. public enum AsterismSchemaV4: VersionedSchema {     public static let versionIdentifier = Schema.Version(4, 0, 0) 
Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swiftindex 06dc565..166da3e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift@@ -193,7 +193,7 @@ extension LibraryRepository: BackupV4SnapshotProviding { /// Export is V4-only (Req 5.2): it decode-validates its own bytes before /// sharing, so a produced file is always a valid strict Backup V4 document, and /// it refuses while any Site is quarantined via the snapshot provider (Req 9.4).-public final class BackupV4Exporter: @unchecked Sendable {+public final class BackupV4Exporter: Sendable {     private let repository: any BackupV4SnapshotProviding     private let stagingDirectory: URL 
Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift Modified +10 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swiftindex 843fb01..c75d897 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift@@ -378,7 +378,11 @@ public enum ComposedTeachingProjectionPlanner {             }         } -        let prospectiveWorks = (try? ProspectiveWorkBatchPlanner.plan(entries: prospectiveEntries)) ?? []+        // Must throw, not fail open: the entry assignments above already carry+        // `.create(key:)`, and an empty plan would make the commit silently skip+        // every prospective Work while still reporting `.committed`. The caller+        // maps a thrown error to a typed `.invalidated` outcome (Req 9.1).+        let prospectiveWorks = try ProspectiveWorkBatchPlanner.plan(entries: prospectiveEntries)         let unsettled = !titleSuppliesChapter(request.titleDefinition)             && !(request.urlDefinition?.suppliesSequence ?? false) @@ -463,10 +467,14 @@ public enum ComposedTeachingProjectionPlanner {             rules: [rule], entries: evidenceEntries, works: evidenceWorks)         let projection = try URLIdentityPlanner.derive(basis: evidenceBasis, rule: rule) +        // Index once: `projection.evidence(for:)` is a linear scan, and calling it+        // per Work makes this quadratic against the 5,000-Entry budget (Req 8.5).+        let projectedEvidence = Dictionary(+            projection.works.map { ($0.workID, $0.evidence) }, uniquingKeysWith: { first, _ in first })         var evidenceByWorkID: [UUID: WorkIdentityEvidence] = [:]         var workProjections: [ComposedWorkProjection] = []         for work in basis.works {-            let evidence = projection.evidence(for: work.id)+            let evidence = projectedEvidence[work.id]                 ?? .noEntries(previousIdentity: work.identity)             evidenceByWorkID[work.id] = evidence             let disposition = WorkIdentityResolver.resolve(
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swiftindex e14ce40..da29027 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift@@ -133,8 +133,8 @@ extension LibraryRepository {             var candidates: [String] = [rawURL]              if let site = try Self.fetchSites(hostname: hostname, context: context).first,-               let currentRule = site.urlRuleValues.first(where: { $0.isCurrent }),-               let definition = try? currentRule.definition {+               let currentRule = site.urlRuleValues.first(where: { $0.isCurrent }) {+                let definition = currentRule.definition                 // v2 candidate: an identity+sequence rule identifies the chapter                 // without a title (Safari page shares supply one; others may not).                 if definition.suppliesIdentity,
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift Modified +6 / -6
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swiftindex cee3c6b..a604566 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift@@ -263,7 +263,7 @@ extension LibraryRepository {             let timestamp = self.clock.now()              // Detect whether reapplying the current rules changes any derived value.-            guard Self.composedOutcomeChangesState(+            guard try Self.composedOutcomeChangesState(                 context: context, hostname: hostname, outcome: currentOutcome,                 titleRuleID: activePattern.id, titleVersion: activePattern.version,                 titleDefinition: contract.request.titleDefinition,@@ -309,17 +309,17 @@ extension LibraryRepository {         titleRuleID: UUID, titleVersion: Int, titleDefinition: PatternDefinition,         titleTrimPrefix: String?, titleTrimSuffix: String?,         url: (id: UUID, version: Int, definition: URLRuleDefinition)?-    ) -> Bool {+    ) throws -> Bool {         if !outcome.prospectiveWorks.isEmpty { return true }         let titleRule = ComposedTitleRule(             definition: titleDefinition, trimPrefix: titleTrimPrefix, trimSuffix: titleTrimSuffix)         let urlRule = url.map { ComposedURLRule(definition: $0.definition) } -        let entries = (try? context.fetch(-            FetchDescriptor<Entry>(predicate: #Predicate { $0.hostname == hostname }))) ?? []+        let entries = try context.fetch(+            FetchDescriptor<Entry>(predicate: #Predicate { $0.hostname == hostname }))         let entriesByID = Dictionary(entries.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })-        let works = (try? context.fetch(-            FetchDescriptor<Work>(predicate: #Predicate { $0.siteHostname == hostname }))) ?? []+        let works = try context.fetch(+            FetchDescriptor<Work>(predicate: #Predicate { $0.siteHostname == hostname }))         let worksByID = Dictionary(works.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })          for wp in outcome.works {
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swift Modified +12 / -6
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swiftindex c003d45..a62bd11 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swift@@ -158,10 +158,16 @@ extension LibraryRepository {         identity: ExactScalarString?, name: String?, works: [ComposedWorkBasis]     ) -> ComposedAssignmentProjection {         guard let name, !M2Unicode.isBlank(name) else { return .noChange }--        func matchTitle(_ work: ComposedWorkBasis) -> String {-            if let lpt = work.lastParsedTitle, !M2Unicode.isBlank(lpt) { return lpt }-            return work.displayTitle+        // Titles compare by exact scalars, never by `String ==` (which is+        // canonical equivalence): the teaching sweep matches through+        // `ExactScalarString`, and capture must reproduce its outcome for+        // identical inputs (Req 6.3). Byte-distinct but canonically equal titles+        // stay distinct on both paths.+        let parsedName = ExactScalarString(name)++        func matchTitle(_ work: ComposedWorkBasis) -> ExactScalarString {+            if let lpt = work.lastParsedTitle, !M2Unicode.isBlank(lpt) { return ExactScalarString(lpt) }+            return ExactScalarString(work.displayTitle)         }          if let identity {@@ -172,14 +178,14 @@ extension LibraryRepository {             if identityMatches.count > 1 { return .ambiguous(workIDs: identityMatches.map(\.id)) }              let titleClaims = works.filter {-                matchTitle($0) == name && $0.identity.state == .none+                matchTitle($0) == parsedName && $0.identity.state == .none             }.sorted { $0.id.uuidString < $1.id.uuidString }             if titleClaims.count == 1 { return .claim(workID: titleClaims[0].id) }             if titleClaims.count > 1 { return .ambiguous(workIDs: titleClaims.map(\.id)) }             return .create(key: .urlIdentity(identity))         } -        let titleMatches = works.filter { matchTitle($0) == name }+        let titleMatches = works.filter { matchTitle($0) == parsedName }             .sorted { $0.id.uuidString < $1.id.uuidString }         switch titleMatches.count {         case 0: return .create(key: .title(ExactScalarString(name)))
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift Modified +5 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swiftindex 15b3502..2e2a789 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift@@ -301,8 +301,11 @@ extension LibraryRepository {                     to: entry, derivation: derivation,                     titleRuleID: activePattern.id, titleVersion: activePattern.version, url: url) -                let allWorks = try context.fetch(FetchDescriptor<Work>())-                    .filter { $0.siteHostname == validated.hostname }+                // Predicated, not a full-table fetch filtered in memory: this is+                // the share extension's commit path.+                let capturedHostname = validated.hostname+                let allWorks = try context.fetch(FetchDescriptor<Work>(+                    predicate: #Predicate { $0.siteHostname == capturedHostname }))                 Self.applyCaptureAssignment(                     to: entry, assignment: contract.outcome.composedAssignment, derivation: derivation,                     titleRuleID: activePattern.id, titleVersion: activePattern.version, url: url,
Packages/AsterismCore/Sources/AsterismCore/M3PerformanceSignposts.swift Modified +9 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceSignposts.swift b/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceSignposts.swiftindex 2a95b86..85e40a6 100644--- a/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceSignposts.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceSignposts.swift@@ -3,9 +3,15 @@ import Foundation /// Stable names consumed by XCTest/Instruments physical-device performance hooks. /// Keep these values source-compatible so historical measurements remain comparable. ///-/// Design §8.7: URLTeachingViewModel emits these signposts to enable the M3-/// physical-device protocol that measures edit acknowledgement and final preview-/// publication under the exact 5,000-Entry URL-identity fixture.+/// Design §8.7: the composed teaching surface (`ComposedTeachingViewModel`,+/// which absorbed the deleted `URLTeachingViewModel`) emits these signposts to+/// enable the M3 physical-device protocol that measures edit acknowledgement and+/// final preview publication under the exact 5,000-Entry fixture.+///+/// The names below still read `URLTeaching*` deliberately: renaming them would+/// break comparison against the recorded M3 baselines. `ComposedTeachingViewModel`+/// additionally emits `ComposedEditAcknowledgement` /+/// `ComposedFinalPreviewPublication` for the M4 composed budgets (Req 8.5). public enum M3PerformanceSignposts {     public static let subsystem = "me.nore.ig.Asterism"     public static let category = "M3Performance"
Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swift Modified +5 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swift b/Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swiftindex 0252491..6af5482 100644--- a/Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/V4LibraryValidator.swift@@ -416,11 +416,13 @@ public enum V4LibraryValidator {                   let storedSequence = entry.chapterSequence else {                 throw invalid("Entry", id, "sequence extraction replay cannot resolve its retained rule")             }+            // `definition` decodes JSON on every access; read it once per Entry.+            let definition = rule.definition             let replayed: ExactScalarString?-            if case .sequence = rule.definition {-                replayed = try? URLRuleApplicator.applySequence(rule.definition, to: ExactScalarString(entry.rawURLString))+            if case .sequence = definition {+                replayed = try? URLRuleApplicator.applySequence(definition, to: ExactScalarString(entry.rawURLString))             } else {-                replayed = (try? URLRuleApplicator.apply(rule.definition, to: ExactScalarString(entry.rawURLString)))?.chapterSequence+                replayed = (try? URLRuleApplicator.apply(definition, to: ExactScalarString(entry.rawURLString)))?.chapterSequence             }             guard let replayed, replayed == ExactScalarString(storedSequence) else {                 throw invalid("Entry", id, "stored sequence does not equal retained-rule replay")
docs/agent-notes/composed-teaching-ui.md Modified +52 / -8
diff --git a/docs/agent-notes/composed-teaching-ui.md b/docs/agent-notes/composed-teaching-ui.mdindex a93a61a..6075023 100644--- a/docs/agent-notes/composed-teaching-ui.md+++ b/docs/agent-notes/composed-teaching-ui.md@@ -40,15 +40,59 @@ it needs share-sheet automation infrastructure that does not exist today.   M3-surface deletion (only its own tests referenced it), and its M2 title-editor   view-model mechanics are covered for the composed surface by   `ComposedTeachingViewModelTests`.-- `TeachingComponents.swift` is **kept**: `SegmentChipView` and `FlowLayout` are+- `TeachingComponents.swift` is **kept**: `TitleChipView` and `FlowLayout` are   used by `ComposedTeachingView` and `ComposedURLDetailsEditor`. - No dead M2-era extension capture wiring was found: the M2 capture stack   (`CaptureViewModel`/`ObservableCaptureViewModel`/`CaptureView`/   `CaptureCoordinator`) is fully reused by the lookup-first `.new` handoff (Q26)-  in `ShareCaptureRootView`. `WorkOnlyTitleCleaner`/`V3LibraryValidator`/-  `SiteTitleInterpretation` remnants are reserved for task 25.-- The composed title editor implements whole-title (kept-span + trims, the new-  primary interaction) and WC segment/phrase; the chapter-less **segment/phrase**-  forms are authored via whole-title + trims rather than a dedicated-  chapter-optional segment editor. All motivating cases (tthfanfic prefix trim,-  Work-only, sequence-only) are reachable through whole-title + URL sequence.+  in `ShareCaptureRootView`. `WorkOnlyTitleCleaner`, `V3LibraryValidator`, and+  `SiteTitleInterpretation` were removed by the task-25 finalization.+## Title selection after Decision 8 (tasks 26–31 — DONE)++There is **no title-mode picker**. `ComposedTeachingViewModel` holds one chip row+(`titleChips` + `titleRoles`, roles Work / chapter / ignore) and the rule form is+*derived* from it by `ComposedTeachingPresentation.inferredTitleRule`, per the+Req 8.6 table. Chips are whole delimiter-split segments by default; a selected+multi-part segment's scissors control replaces it in place with its parts.+`AsterismCore` was not touched: subdivided selection derives `.phrase` from two+character spans through the existing `PhrasePatternDeriver`.++Two behaviours are easy to break and are covered by tests:++- **Illegal selections are unreachable, not reported.** `cycleTitleRole` builds+  each candidate role, normalises it (`normalizedRoles` keeps each role's chips+  one contiguous run), and only accepts candidates that infer a rule. The+  common rejection is a whitespace-only `.phrase` separator: parts are maximal+  alphanumeric runs, so `Some Story 12` cannot mark `12` as the chapter (the+  separator would be `" "`, which `M2Unicode.isBlank` rejects). Chapter is+  skipped in the cycle for those titles — their chapter has to come from a URL+  sequence. The cycle also prefers a candidate that does not+  demote *other* chips, otherwise passing through an intermediate role silently+  wipes the reader's existing selection.+- **Re-teach fidelity.** `retainedTitleRule` + `titleEdited` still make the+  commit reuse the Site's own definition verbatim until the title is edited;+  `seedTitleEditor` now re-seeds the chip row (segment anchors inverted directly,+  whole-title trims and phrase literals located back onto character spans).+  `trimPrefix`/`workNamePreview` read the *effective* rule, so they show the+  retained rule before any edit.++Accepted loss (Decision 8): a boundary inside an alphanumeric run is no longer+expressible from the title; the URL sequence source is the remedy, and the URL+disclosure auto-expands whenever the selection sources no chapter.++### Why subdivision is a control, not a repeat tap++A repeat tap cannot mean both "advance the role" and "subdivide" when the+segment tap is a three-role cycle. Subdivision is therefore a scissors control+**inside the chip, shown only on already-selected multi-part segments** — still+inline in the same row, no sheet and no second screen. Req 8.3 was amended to+match (see Q30 in the decision log); this note is the rationale, not a+divergence.++### Accessibility identifiers are load-bearing here++`.accessibilityIdentifier` on a container **without** `.accessibilityElement(children: .contain)`+collapses the subtree into one element and hides its buttons from XCUITest (and+from VoiceOver as separate controls). That is what made the unsettled-chapters+acknowledgment button unqueryable. Containers that hold controls need both+modifiers.
docs/agent-notes/schema-migration.md Modified +86 / -117
diff --git a/docs/agent-notes/schema-migration.md b/docs/agent-notes/schema-migration.mdindex 5b5ec7d..c747deb 100644--- a/docs/agent-notes/schema-migration.md+++ b/docs/agent-notes/schema-migration.md@@ -1,125 +1,94 @@ # Schema migration (V3 → V4) -## The V3 model snapshot cannot be created before V4 (task 10.2)--`unified-teaching-composition` task 1 asked to "freeze the V3 model classes as-snapshots inside the `AsterismSchemaV3` namespace" (Q22 / Decision 3). This was-attempted and **reverted** — it crashes the SwiftData runtime.--`AsterismSchemaV3.models` returns the *live* top-level `@Model` classes (`Entry`,-`Work`, `Site`, `TitlePattern`, `URLRulePattern`), and the whole repository-fetches those types. Defining nested snapshot copies (`AsterismSchemaV3.Entry`,-etc.) that carry the **same SwiftData entity names** ("Entry", "Site", …) makes-SwiftData's global entity registry ambiguous. Even while the snapshots are inert-(in no active schema), materialization fails at runtime:+Schema V4 is live. The app and the share extension both open `openV4ForApp` /+`openV4ForExtension`. Read the "Current state" section; everything under+"History" is background for the *next* schema bump and describes states that no+longer exist.++## Current state++- **Every `@Model` is nested; there are zero top-level `@Model` types.** The live+  classes live in `extension AsterismSchemaV4 { @Model final class Entry … }`+  (`Models.swift`) and are reached by top-level typealiases+  (`typealias Entry = AsterismSchemaV4.Entry`). The frozen pre-M4 shape is nested+  in `AsterismSchemaV3` (`AsterismSchemaV3.swift`): its `Site` keeps+  `titleInterpretationRaw` + `workTitleTrimRule` and has none of the M4-additive+  columns.+- **`Site.titleInterpretationRaw` and `workTitleTrimRule` are gone** from the+  live V4 `Site`. `V4Migration.buildSidecar` reads them off the frozen+  `AsterismSchemaV3.Site` before conversion. The only surviving references are+  the frozen V3 snapshot, the frozen `BackupV3Types` wire copies+  (`FrozenTitleInterpretation` / `FrozenWorkTitleTrimRule`, keeping the 3/3+  format byte-identical), the V2→V3 / V3→V4 backup mappers, and the+  migration-read path. No live reader touches them.+- **"Work-only" is now derived, not stored:** `Site.isWorkOnlyTitleRule` (the+  active pattern's definition is `.wholeTitle`). Trims apply via+  `TitleTrimApplicator` on the pattern's `trimPrefix`/`trimSuffix`. A taught Site+  always holds exactly one active title pattern (Decision 5).+- **`AsterismV4MigrationPlan` = `[V3, V4]` with a `.lightweight` stage.** The+  Work-only → whole-title-rule transform and the durable-sidecar crash safety are+  orchestrated manually by `openV4ForApp` under the exclusive lock, app-only:+  read the V3 shape → write the sidecar (before conversion) → open V4 →+  `V4Migration.runCompletionPass` (create whole-title patterns with the+  pre-allocated UUIDs under an exact-idempotence guard; backfill+  `conservativeIdentityKey`) → `V4LibraryValidator` → publish `AsterismV4.ready`+  (`"4"`) → delete the V3 marker and the sidecar. See+  `LibraryRepository+V4Bootstrap.swift` and `V4Migration.swift`.+- **Deleted:** `V3LibraryValidator` (→ `V4LibraryValidator`),+  `SiteTitleInterpretation`, `WorkOnlyTitleCleaner`, `openV3ForApp` /+  `openV3ForExtension`, and the M3 URL-teaching commit subsystem+  (`URLTeachingProjection` + the `commit/projectURLTeaching*` repository+  methods). `openV3Container` survives only as an internal helper used once by+  the bootstrap to read the pre-migration shape. `reviewURLIdentity` and+  `URLIdentityPlanner` survive (Req 7.2). `commitTeaching` survives minus its+  interpretation stamp. The `WorkTitleTrimRule` *struct* is retained as the+  frozen V3 Site's stored-column type.+- **Capability gate is `.m4`** (`AsterismCapabilities.current`). `BackupV3Codec`+  is pinned to its historical `"m3"` gate so every earlier backup stays+  byte-identical; `BackupV4Codec` carries `"m4"`.+- **Backup:** `planV4` / `BackupV4Exporter` / the V4 confirm-import commit path+  are primary. Import dispatches 2/2→V4, 3/3→V4, and native 4/4; the 3/3 path+  keeps `BackupV3ReferenceValidator` (Q18). The frozen 2/2 and 3/3 codecs and+  both mappers stay.++## History — lessons for the next schema bump++### Nesting every entity is what makes an in-module snapshot possible++An early attempt to freeze V3 as nested snapshots *while the live classes stayed+top-level* crashed the SwiftData runtime:  ``` SwiftData/ModelContext.swift:712: Fatal error: Failed to cast model AsterismCore.Site for PersistentIdentifier(... Site/p1) to Site. ``` -Two `@Model` types sharing one entity name in the **same module** is the-problem. The V1 precedent (`AsterismV1MigrationSupport`) avoids it by putting-frozen models in a **separate module** used only by an offline migration tool —-but in-process V3→V4 migration needs both schema versions in `AsterismCore`.--### What task 1 did instead--Task 1 applied only the additive live-class changes (new columns/arms) and left-`AsterismSchemaV3.models` pointing at the live classes. The freeze is documented-as a plan in `AsterismSchemaV3.swift`.--### What task 10.2 actually did (and what it deferred — read this)--Task 10.2 landed the V4 schema, the migration, and the bootstrap **without** the-nested freeze and column drop, because the freeze/drop is inseparable from-rewriting ~15 call sites that later phases own. This is the same reasoning that-moved Q22's freeze out of task 1 (Decision 6): the drop is not a smaller earlier-step; it is one step with the rewrites. Empirical findings that forced the shape:--1. **A custom `MigrationStage.custom(V3→V4, willMigrate:…)` never fires** when V3-   and V4 share the live `@Model` classes — SwiftData sees no schema diff. Probed-   directly. So the sidecar is **not** written from `willMigrate`. A custom stage-   would also run inside the share extension, which must never migrate (Req 5.4).-2. **The nested freeze cascades and breaks the build.** Making `AsterismSchemaV3`-   return frozen nested snapshots (the only way V3 can genuinely lack the M4-   columns so a real additive migration exists) requires either a top-level-   `@Model` that collides (the phase-1 crash) or moving the live classes fully-   nested. Either way the M3 machinery that still fetches live models from a V3-   container — `openV3Container`, `LibraryRepository+BackupImport`, the live-   `openV3ForApp/ForExtension` path, and the `V3Bootstrap`/`BackupImportTransaction`-   test helpers — stops compiling, and dropping `Site.titleInterpretationRaw` /-   `workTitleTrimRule` additionally breaks ~15 files (`URLIdentityPlanner`,-   `URLTeachingProjection`, `RecentPresentation`, `LibraryRepository+URLIdentity`-   /`+EntryDetail`/`+ReparseCapture`/`+Contracts`, `LibraryProviding`, the M3-   perf fixture, …) plus the M3 teaching/import **write** paths that set the-   interpretation column. All of that is phases 11–17/24.--**Shape shipped in 10.2 instead (green, faithful to Decision 3's intent):**--- `AsterismSchemaV4` (version 4.0.0) returns the **live** classes — same set as-  `AsterismSchemaV3` still does. `AsterismV4MigrationPlan` = `[V3, V4]` with a-  **`.lightweight`** stage (opens a V3-recorded store under the V4 schema; adds-  the M4-additive nullable/defaulted columns for a real pre-M4 store).-- The Work-only → whole-title-rule transform and the durable-sidecar crash-  safety are orchestrated **manually by `openV4ForApp`** under the exclusive lock,-  app-only: read the V3 shape → write the sidecar (before conversion) → open V4 →-  `V4Migration.runCompletionPass` (create whole-title patterns with the-  pre-allocated UUIDs, exact-idempotence guard; backfill `conservativeIdentityKey`)-  → `V4LibraryValidator` → publish `AsterismV4.ready` (`"4"`) → delete the V3-  marker + sidecar. `LibraryRepository+V4Bootstrap.swift`, `V4Migration.swift`.-- `openV3Container` / `openV3ForApp` / `V3LibraryValidator` are **untouched and-  still live** — the app runtime still opens via V3 until the UI phase wires-  `openV4ForApp`. Both open paths coexist over the same `AsterismV3.sqlite` file-  (Q13), gated on their own markers.--## Task 25 finalization — DONE (schema V4 is now live)--The finalization shipped. Final state:--- **Nested freeze (Decision 6).** The live classes are nested in-  `extension AsterismSchemaV4 { @Model final class Entry … }` (in `Models.swift`),-  reached by top-level `typealias Entry = AsterismSchemaV4.Entry` etc. The frozen-  pre-M4 shape is nested in `AsterismSchemaV3` (`AsterismSchemaV3.swift`): `Site`-  keeps `titleInterpretationRaw` + `workTitleTrimRule`, and none of the M4-additive-  columns exist there. There is exactly one *top-level* `@Model` per entity name-  (zero — they are all nested; the top-level names are typealiases). Proven safe:-  `V4MigrationBootstrapTests` seeds genuine frozen-`AsterismSchemaV3` stores, then-  `openV4ForApp` opens+migrates them under the `[V3,V4]` plan in-process without a-  `ModelContext` entity-name collision crash.-- **Dropped columns.** `Site.titleInterpretationRaw` + `workTitleTrimRule` are-  gone from the live V4 `Site`. `V4Migration.buildSidecar` reads them off the-  frozen `AsterismSchemaV3.Site` before conversion. The only remaining references-  to the dropped columns/types are the frozen `AsterismSchemaV3` snapshot, the-  `BackupV3Types` frozen wire copies (`FrozenTitleInterpretation` /-  `FrozenWorkTitleTrimRule`, so the 3/3 format is byte-unchanged), the V2→V3 / V3→V4-  backup mappers, and the migration-read code — no live reader off the live Site.-- **Reader rewrite.** "Work-only" is now `Site.isWorkOnlyTitleRule` (active pattern-  definition is `.wholeTitle`); trims apply via `TitleTrimApplicator` on the-  pattern's `trimPrefix`/`trimSuffix`. A taught Site always holds exactly one active-  title pattern (Decision 5), so the reader corruption checks collapsed.-- **Deleted.** `V3LibraryValidator` (callers → `V4LibraryValidator`),-  `SiteTitleInterpretation`, `WorkOnlyTitleCleaner`, and the M3 URL-teaching commit-  subsystem (`URLTeachingProjection` + the `commit/projectURLTeaching*` repository-  methods). `reviewURLIdentity` + `URLIdentityPlanner` survive (Req 7.2); the M2-  title-teaching (`commitTeaching`) survives, minus its interpretation stamp. The-  `WorkTitleTrimRule` *struct* is kept as the frozen V3 Site's stored-column type.-- **Runtime.** App (`AppLibraryModel`), extension (`ShareViewController`), and the-  store test helper open `openV4ForApp`/`openV4ForExtension`. The composed teaching-  surface commits against the real V4 runtime in production; the UI-test composed-  fixture folds into the normal V4 setup path. The `quarantined` map was already-  wired from the open path's `validateV4Store` diagnoses (Q29).-- **Backup.** `planV4` / `BackupV4Exporter` / the V4 confirm-import commit path are-  primary; the V3 `plan` / `BackupExporter` / V3 export mappers / V3 import-commit-  are retired. Import dispatches 2/2→V4, 3/3→V4, 4/4 native; the 3/3 path keeps-  `BackupV3ReferenceValidator` (Q18). The frozen 2/2 and 3/3 codecs + both mappers-  stay.--## Other Foundation deferrals into later phases--- `AsterismCapabilities.current` stays `.m3` after Foundation. Flipping it to-  `.m4` belongs with the backup 4/4 work (task 16): `BackupV3Codec` currently-  stamps `AsterismCapabilities.current.gate.rawValue`, so flipping `current`-  early would write a `3/3` backup with gate `"m4"`. Task 16 must pin-  `BackupV3Codec` to `"m3"` and have `BackupV4Codec` carry `"m4"` before (or as)-  it flips `current`.+Two `@Model` types sharing one entity name in the same module makes SwiftData's+global entity registry ambiguous, and materialization fails even while the+snapshots are inert. The V1 precedent (`AsterismV1MigrationSupport`) sidesteps+this with a separate module, but in-process V3→V4 migration needs both schema+versions inside `AsterismCore`.++**The fix, now proven in production:** nest *every* entity so there are zero+top-level `@Model` types, and make the top-level names typealiases.+`V4MigrationBootstrapTests` seeds genuine frozen-`AsterismSchemaV3` stores and+`openV4ForApp` migrates them in-process under the `[V3,V4]` plan with no+collision. Do the same for V5 — do not conclude from the crash above that+in-module snapshots are impossible.++### A custom migration stage is the wrong tool here++`MigrationStage.custom(V3→V4, willMigrate:…)` never fires when the two versions+share structurally identical models — SwiftData sees no schema diff (probed+directly). It would also run inside the share extension, which must never+migrate (Req 5.4). That is why the sidecar is written by `openV4ForApp` rather+than from `willMigrate`.++### The freeze and the column drop are one step++Freezing V3 is not implementable while the live classes are still V3's, and+dropping the two `Site` columns is inseparable from rewriting every reader of+them. Decision 6 records why the freeze moved out of task 1, and Decision 7 why+finalization sequenced *after* the Core runtime rewrite rather than inside the+migration phase. Expect the same coupling next time: plan the snapshot freeze to+land with the phase that rewrites the readers, not before it.
docs/agent-notes/testing.md Modified +1 / -1
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 402aae3..fd30b66 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -16,7 +16,7 @@ and the only navigation-driven tests were device-only performance tests that silently bail on missing elements. Lesson (also encoded as `specs/unified-teaching-composition` Req 7.3): every UI deliverable needs at least one simulator UI test that reaches it from app launch via real navigation-(see `AsterismUITests/URLTeachingFlowUITests.swift` for the pattern, and use the+(see `AsterismUITests/ComposedSurfaceUITests.swift` for the pattern, and use the seeded scenarios in `UITestLaunchSupport`).  ## Misc
specs/OVERVIEW.md Modified +3 / -2
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 6a5cc62..3982859 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -44,7 +44,8 @@ Adds exact URL-derived identity, safe re-share editing, conflict recovery, confi  Replaces the site-level title/URL interpretation fork with per-field teaching source composition. -- [requirements.md](unified-teaching-composition/requirements.md)+- [decision_log.md](unified-teaching-composition/decision_log.md) - [design.md](unified-teaching-composition/design.md)+- [implementation.md](unified-teaching-composition/implementation.md)+- [requirements.md](unified-teaching-composition/requirements.md) - [tasks.md](unified-teaching-composition/tasks.md)-- [decision_log.md](unified-teaching-composition/decision_log.md)
specs/unified-teaching-composition/decision_log.md Modified +52 / -0
diff --git a/specs/unified-teaching-composition/decision_log.md b/specs/unified-teaching-composition/decision_log.mdindex e052fa8..0809ab2 100644--- a/specs/unified-teaching-composition/decision_log.md+++ b/specs/unified-teaching-composition/decision_log.md@@ -33,6 +33,7 @@ | Q27 | 2026-07-23 | `commitCapture` re-runs the lookup match under the exclusive lock before insert | Lookup-to-commit race must produce an edit/ambiguous disposition, not a duplicate Entry | | Q28 | 2026-07-23 | Quarantine semantics approved: re-teach is the repair path; export blocks loudly with Site-naming typed error | Reader-confirmed; folded into Req 9.4 (design review F1) | | Q29 | 2026-07-23 | `AsterismV4.ready` certifies store-level validity only; per-Site quarantine may coexist | Reader-confirmed; folded into Req 5.4 — the only reading where 5.4 and 9.4 don't contradict (design review F5) |+| Q30 | 2026-07-25 | Subdivision is a distinct inline control on the chip, not a repeat tap | Req 8.3 as drafted said "selecting an already-selected segment SHALL subdivide it", but the segment tap is already a three-role cycle (chapter/Work/ignore), so a repeat tap cannot mean both. A scissors control on selected multi-part segments keeps the inline, no-navigation intent; 8.3 amended to match |  ## Decision 1: Progressive disclosure of URL teaching within one composed surface @@ -280,3 +281,54 @@ Dropping the columns is inseparable from rewriting the teaching/capture/import * - The finalization task (25) is a late, cross-cutting change gated on seven upstream tasks.  ---++## Decision 8: Infer the title rule form from chip selection instead of a title-mode picker++**Date**: 2026-07-25+**Status**: accepted++### Context++The shipped composed teaching surface presents a four-button title-mode picker — Whole title, Segments, Phrase, Articles — and swaps the editor beneath it. The reader must classify their Site into the model's taxonomy *before* making any selection, which means understanding the rule forms in order to begin. Each mode then brings its own controls: the kept-span chip row plus four character-level boundary steppers, the segment chip row, two phrase cards each with their own steppers, and the articles stepper. With the URL disclosure and its slot picker, chip rows, split editor, and clear button, the surface carries far more chrome than the single decision it exists to capture.++Two further problems are structural rather than cosmetic. Articles is not a title rule at all — it is a one-way Site transition that retires the active pattern and the current URL rule — yet it sits as a peer in the title-mode picker. That category error is exactly why it shipped unreachable: it could never satisfy `buildTitleDefinition()`, so the mode was selectable but could never commit. And Req 8.3's promise that character-level boundary controls are "retained for sub-token precision" for path components and within-component splits was never delivered on the URL side, which has chip selection only.++### Decision++Remove the title-mode picker. The example title renders as its delimiter-split segments; tapping cycles a segment's role, and tapping an already-selected segment subdivides it in place into its constituent parts, which become individually selectable in the same chip row. The rule form is inferred from what was selected (Req 8.6's table) and is never named by the reader. Character-level boundary controls are removed entirely rather than demoted. When the title selection leaves the chapter unsourced, the URL details expand automatically as the remedy. Articles moves out of title selection to its own affordance (Req 8.7).++### Rationale++The reader's actual intent is "this bit is the story name, that bit is the chapter" — a pointing gesture, not a classification. Every legal form is reachable from pointing at two granularities, so the taxonomy can be derived rather than asked for. That deletes four controls outright and removes the need to understand `.segment` versus `.phrase` to start.++The change is far cheaper than it appears because `.phrase(prefix:separator:suffix:order:)` already means "arbitrary exact literals around and between two fields". Subdivided selection derives that existing form from character spans — precisely what `PhrasePatternDeriver.derive` already does — so no new `PatternDefinition` arm, no capability-gate change, no validator tuple-table change, and no backup wire-format change. The span-selection machinery (`tokenRanges` / `adjustedSpan`) already exists and is already shared between the title and URL editors.++Subdividing in place rather than opening a focused second level follows the milestone's own precedent: Req 8.3 deleted the URL-teaching sheet's dismiss-then-present handoff specifically because composing sources should not require navigating between screens. Reintroducing a navigation step for within-segment refinement would contradict that.++Keeping segment granularity as the default is deliberate. `.segment` matches positionally and survives a Site whose titles vary; `.phrase` matches on exact literals and does not. Making the more brittle form the *easier* one to reach would raise per-Entry parse failures across a library, so subdivision is a second tap rather than the initial state.++### Alternatives Considered++- **Open a focused second level for refinement**: a "Refine" affordance opening a dedicated editor for one segment, then returning. Rejected — it reintroduces exactly the navigation step Req 8.3 removed for URL teaching, and the top-level simplicity it buys is small once the mode picker is gone.+- **Always show the finest parts, grouped by segment**: no drill-in at all. Rejected — a twelve-part title becomes a wall of small chips, and it nudges the reader toward `.phrase` when the more robust `.segment` is nearly always the better answer.+- **Demote the boundary steppers rather than remove them**: show them only inside a subdivided segment. Rejected by the reader in favour of outright removal; the accepted loss is recorded under Consequences.+- **Keep the mode picker and only reduce its options**: e.g. fold Whole title into Segments. Rejected — it leaves the reader classifying before selecting, which is the actual problem.++### Consequences++**Positive:**+- Four controls removed from the surface, and the reader no longer needs to understand the rule taxonomy to begin.+- The gap between what the reader points at and what gets authored closes; there is no longer a mode that can be selected but not committed.+- Articles' category error is resolved structurally, so it cannot regress to dead code by failing to fit a title-form builder.+- No schema, capability-gate, validator, or backup-format change — the closed tuple set of Req 1.1 is untouched.++**Negative:**+- A boundary *inside* an alphanumeric run is no longer expressible from the title: parts split on alphanumeric runs, so `Ch12` cannot be separated into `Ch` and `12`. The remedy for such Sites is the URL sequence source, which the same flow now surfaces automatically — but a Site with a glued-together chapter and no useful URL becomes unsettleable from the title alone.+- Subdivided selection authors `.phrase`, whose exact-literal matching is more brittle across title variation than `.segment`. The default ordering mitigates but does not eliminate this; the per-Entry preview remains the reader's signal.+- Req 3.3, 8.2, and 8.3 are amended after the milestone was marked Done, and the shipped UI tests for the kept-span interaction and mode picker will need rewriting rather than extending.++### Impact++`ComposedTeachingView` (mode picker, kept-span selector, phrase editor, boundary controls), `ComposedTeachingViewModel` (`TitleMode`, `selectTitleMode`, kept-span state, phrase state, and the `buildTitleDefinition` inference), and the composed-surface UI tests. `AsterismCore` is unaffected: no rule form, validator, gate, or wire format changes.++---
specs/unified-teaching-composition/design.md Modified +7 / -2
diff --git a/specs/unified-teaching-composition/design.md b/specs/unified-teaching-composition/design.mdindex b2127f5..1523322 100644--- a/specs/unified-teaching-composition/design.md+++ b/specs/unified-teaching-composition/design.md@@ -102,10 +102,15 @@ Recalculation reapplies the *unchanged* current rules, so it cannot route throug  App-side: `TeachingView`/`TeachingViewModel` evolve into `ComposedTeachingView`/`ComposedTeachingViewModel` (evolution rather than a fresh view to preserve the articles editor mode and M2 title editors' tested behavior in place; the type is renamed). Structure, top to bottom: -1. **Kept-span title selector (Req 3.3)** — the milestone's new title interaction. The example title renders as the existing token chip row (segment-editor pattern); the reader marks the kept span by selecting its first and last kept tokens, with the M3 character-level boundary controls for sub-token precision. Kept span = whole title → whole-title rule, no trims; narrower → leading/trailing trims derived from the discarded affixes. A `LabeledContent` "Work will be named" row previews the result live. Mid-title spans produce both affixes; that is the designed meaning.-2. **Field editors** — segment/phrase/articles editor modes operate within the kept span; the chapter selection is optional (Req 2.2's relaxation), and clearing it routes through the unsettled-chapters acknowledgment when no URL sequence is selected.+1. **Two-granularity title chip selector (Req 3.3, 8.3, 8.6 — revised by Decision 8).** The example title renders as its delimiter-split segments. Tapping a segment cycles its role (chapter → Work → ignore, the existing `cycleRole` semantics). Tapping an **already-selected** segment a second time *subdivides it in place* into its constituent parts (maximal alphanumeric runs, from the existing `tokenRanges`), which then become individually selectable at the same chip row — no sheet, no second screen. A `LabeledContent` "Work will be named" row previews the result live.++   There is **no title-mode picker**: the form is inferred from the selection per the Req 8.6 table. Segment-level selection stays the default because positional `.segment` rules survive title variation that exact-literal `.phrase` rules do not.++   `.phrase` already expresses "arbitrary literal junk around and between two fields" (`prefix + FIELD + separator + FIELD + suffix`), so subdivided selection needs **no new `PatternDefinition` arm** — it derives an existing form from character spans, exactly as `PhrasePatternDeriver.derive` already does. The character-level boundary controls are removed (Decision 8); their only unique capability was a boundary *inside* an alphanumeric run, and the URL sequence source is the remedy for the titles that need it.+2. **Chapter source resolution** — the chapter selection is optional (Req 2.2's relaxation). When the title selection leaves the chapter unsourced, the URL details expand automatically and present themselves as the remedy (Req 8.2 as amended); the unsettled-chapters acknowledgment is offered only after that, for Sites that genuinely have no chapter anywhere. 3. **URL details disclosure (Req 8.2)** — the component list, chip selection, and split editor extracted from `URLTeachingView`, embedded behind a `DisclosureGroup` matching `EntryDetailView.swift:248`. The stock chevron is the non-color expanded/collapsed indicator (Req 8.4 — stated, not assumed). Label per Req 8.2; collapsed-state summary row when selections exist; auto-expand per Req 8.2. 4. **Composed preview and single commit** — one preview (per-Entry before/after, per-Work projections, issues, prospective Works, settlement), one commit button, acknowledgment interstitial when required.+5. **Articles affordance (Req 8.7)** — separated from title selection, not a peer of it. Articles is a one-way Site transition committing through `projectArticles`/`commitArticles`, so it cannot be a title-mode alongside the inferred forms; conflating the two is what left it unreachable in the first implementation. It keeps a distinct affordance with its own confirmation.  `URLTeachingView`, the coordinator's `.teaching` phase, and the `onURLTeachingRequested` dismiss-then-present handoff are deleted; the coordinator survives to drive `PostTeachingWorkURLView`: `commitComposedTeaching`'s outcome carries the landing-URL candidates (M3 5.1's offer, moved per Req 8.3), and the app presents the queue when candidates exist. Entry points unchanged (`EntryDetailView` Teach/Re-teach, `RecentView` pills via `ContentView`); URL-focused contexts open with the disclosure expanded. 
specs/unified-teaching-composition/implementation.md Modified +322 / -0
diff --git a/specs/unified-teaching-composition/implementation.md b/specs/unified-teaching-composition/implementation.mdnew file mode 100644index 0000000..4d94330--- /dev/null+++ b/specs/unified-teaching-composition/implementation.md@@ -0,0 +1,322 @@+# Implementation: Unified Teaching Composition++Branch `feature/unified-teaching-composition` against `origin/main`.++Written after the milestone's original six phases; a seventh phase (the+teaching-surface redesign, Decision 8) landed afterwards and is folded in+below.++---++## Beginner Level++### What Changed++Asterism tracks serialised fiction. When you share a chapter to it, the app has+to work out three things: which **Work** (story) it belongs to, which **chapter**+it is, and whether it has seen this exact page before.++It learns those answers from you once per site — that is "teaching". Before this+change, teaching forced a choice between two mutually exclusive modes:++- **Ordinary**: the page *title* tells us everything (`My Story - Chapter 12`).+- **Work-only**: the *URL* tells us everything, and titles are just the Work name.++Real sites are not that tidy. `tthfanfic.org` puts the story name in the title+(`TtH • Story • The Real Title`) but the story ID and chapter number in the URL+(`/Story-28614-94/...`). Neither mode could express that, and the app forbade+switching between modes, so a site taught wrongly stayed wrong.++This branch removes the two modes entirely. A site now holds **at most one title+rule and at most one URL rule**, and each answer comes from whichever rule+supplies it. Work name from the title, chapter number from the URL — fine. That+is now just a normal configuration, not a special case.++Two other things changed that you would notice:++1. **Teaching is one screen.** Previously title teaching and URL teaching were+   separate screens with a hand-off between them. Now there is one screen: you+   pick the part of the title that names the story, and URL details sit behind a+   "Add URL details" expander. One preview, one Save.+2. **New chapters arrive already sorted.** Before, sharing a new chapter to a+   site you had already taught did *not* apply what you taught — the chapter sat+   unidentified until you ran teaching again. Now the rules apply the moment you+   share.++### Why It Matters++- Sites that were previously un-teachable are now teachable.+- Teaching stops being a chore you have to redo; it applies going forward.+- Your existing library upgrades automatically, without you re-teaching anything.++### Key Concepts++**Work** — one story, containing many chapters.+**Entry** — one shared page (one chapter).+**Site** — a website, holding the rules you taught for it.+**Title rule** — how to read a page title. Either "the whole title is the story+name" (optionally with boilerplate trimmed off the ends), or a structured split+into story-name and chapter parts.+**URL rule** — how to read a page address: which part identifies the story,+which part is the chapter number.+**Identity key** — a fingerprint for a chapter, so re-sharing the same chapter+updates it instead of creating a duplicate.+**Schema migration** — rewriting how data is stored on disk when the app's model+changes. Like renovating a house while you still live in it: it must survive+being interrupted halfway.++---++## Intermediate Level++### Changes Overview++Five workstreams, each landing as a phase:++| Area | Key files |+|---|---|+| Derivation core | `ComposedDeriver.swift`, `TitleParsing.swift`, `URLIdentityParsing.swift`, `RuleDefinitionComparator.swift` |+| Schema V4 | `AsterismSchemaV3/V4.swift`, `V4Migration.swift`, `MigrationSidecar.swift`, `LibraryRepository+V4Bootstrap.swift`, `V4LibraryValidator.swift` |+| Repository ops | `ComposedTeachingProjection.swift`, `LibraryRepository+ComposedTeaching.swift`, `+Capture.swift`, `+ReparseCapture.swift` |+| Backup 4/4 | `BackupV4Types/Codec/Exporter.swift`, `BackupImporter.swift`, `LibraryRepository+BackupImportV4.swift` |+| UI | `ComposedTeachingView(Model).swift`, `ComposedURLDetailsEditor.swift`, `MaintenanceViews.swift`, `ShareCaptureRootView.swift` |++Deleted: `TeachingViewModel`, `URLTeachingView(Model)`, `URLTeachingProjection`,+`URLTeachingCoordinator`, `V3LibraryValidator`, `SiteTitleInterpretation`,+`WorkOnlyTitleCleaner`.++### Implementation Approach++**One derivation function.** `ComposedDeriver.derive(captureTitle:rawURL:+hostname:titleRule:urlRule:)` is the single answer to "what do these rules yield+for this input". Teaching preview, capture commit, recalculation, and re-share+lookup all call it. Requirement 6.4 demands exactly one implementation, and the+practical payoff is that a taught preview and a later capture cannot disagree.++**A closed set of legal states.** Rather than validating fields piecemeal, the+design enumerates every legal (title-rule form × URL-rule form) tuple in a table,+and `V4LibraryValidator` accepts exactly that set. The rule "teaching must be+able to produce every member, and can only produce members" (Req 1.2) closes the+gap where a state is representable but unreachable, or reachable but invalid.++A load-bearing consequence: **a taught site always holds a title rule.** A+"no title rule + URL rule" state would produce output identical to an untrimmed+whole-title rule, so it would be a second spelling of the same thing. Decision 5+records the reasoning.++**Migration in three steps, not a custom stage.** SwiftData's+`MigrationStage.custom` never fires when two schema versions share structurally+identical models, and it would also run inside the share extension, which must+never migrate. So `openV4ForApp` orchestrates manually:++1. Read the pre-migration shape and write a **sidecar** JSON (atomic+   temp-file + fsync + rename, with an embedded SHA-256 self-checksum).+2. Let SwiftData do the lightweight schema conversion.+3. Run a completion pass: create whole-title rules for migrated Work-only sites,+   backfill every Entry's conservative identity key, validate, publish the+   `AsterismV4.ready` marker, delete the sidecar and the old marker.++Every interruption point is a row in a documented state table. Unverifiable+states fail loudly to `libraryUnavailable` rather than guessing.++**Per-site quarantine instead of a dead library.** M3 shipped a bug where one+invalid site made the whole library unopenable. Now `validate(context:)` returns+per-site diagnoses into an in-memory `quarantined` map (never persisted,+recomputed each open). A quarantined site's projections refuse with typed+reasons and captures fall back to conservative behaviour, while launch and every+other site keep working. Re-teaching is the repair path, because its basis reads+only immutable evidence — capture titles and raw URLs — never the broken rules.++**Identity key v3.** A sequence-only site has no Work identity in the URL, so a+`(hostname, sequence)` key would collide across stories. Key v3 embeds the+resolved Work name: `v3|h<len>:<host>|n<len>:<name>|s<len>:<sequence>`. That+makes the key depend on *both* rules, so re-teaching the title recomputes every+v3 key on the site — recorded via `identityNameTitleRuleID/Version`.++### Trade-offs++**Work name, not Work UUID, in the v3 key.** A UUID would be stable across+renames. But keys must be derivable from capture inputs alone: at capture and+lookup time no Work has been resolved yet, and resolving name→UUID first would+smuggle title matching back into identity. The accepted cost is the recompute on+title re-teach.++**A persisted conservative-key alias.** Once a sequence-only commit rewrites keys+to v3, a title-less re-share of the same URL would derive only a v1 candidate and+miss — creating a duplicate where the *untaught* app would have found a match.+Every Entry therefore also stores its v1 key, so same-URL re-shares match+forever. Extra column, but it closes a regression that would only appear after+teaching.++**Recalculation is a separate operation.** It reapplies *unchanged* rules, so it+cannot route through `commitComposedTeaching`, whose no-op detection would+suppress exactly the re-planning it exists to perform. Hence the distinct+`previewRecalculation` / `commitRecalculation` pair.++**Frozen backup formats are deliberately duplicated.** The 2/2 and 3/3 codecs+carry self-contained copies of their types so that editing the live models cannot+retroactively change what an old backup means. This looks like copy-paste and is+not.++---++## Expert Level++### Technical Deep Dive++**No-op detection via canonicalisation.** Req 1.3 forbids re-versioning a rule+whose definition is semantically unchanged, including edits reverted to an+identical definition. Structural `Equatable` is insufficient — two+representations can mean the same rule. `RuleDefinitionComparator` canonicalises+before comparing. This matters beyond tidiness: a spurious version bump would+invalidate stored provenance references across every Entry on the site.++**Exact-scalar equality is load-bearing everywhere.** `ExactScalarString`+compares by `unicodeScalars.elementsEqual`, not Swift's default `String ==`+(which is Unicode *canonical equivalence*). A title differing only by NFC/NFD+composition must stay a distinct Work. This discipline has to hold on every path+that matches or keys — teaching, capture, lookup, and the codecs.++**Commit discipline.** Every previewed mutation runs+refetch → re-project → compare → prospective-graph validate → single `save()`.+A basis change since approval returns `.refreshed` with zero writes; a validation+failure rolls back to a typed `.invalidated`. M3's title-teaching commit was the+one path relying on planner correctness alone; it is absorbed into the validated+composed commit.++Capture is deliberately exempt from full-graph validation (Q9): it validates its+written tuple only, under the existing refetch/compare discipline, because the+share extension is a hot path with a p95 ≤ 100 ms budget for rule application.++**The lookup-to-commit race.** `captureLookup` resolves a disposition, but the+user then edits and saves — during which another process could insert the same+chapter. `commitCapture` re-runs the candidate match under the exclusive lock+immediately before insert, converting the race into an `.edit`/`.ambiguous`+disposition rather than a duplicate.++**Nesting every `@Model` is what makes an in-module snapshot possible.** Two+`@Model` types sharing an entity name in one module makes SwiftData's global+entity registry ambiguous and crashes materialisation, even while the snapshot is+inert. The fix is to nest *every* entity so there are zero top-level `@Model`+types, with the top-level names as typealiases. This is recorded in+`docs/agent-notes/schema-migration.md` because the next schema bump will hit it.++### Architecture Impact++- **The mode fork is gone from the data model**, not just hidden. `Site` no+  longer stores an interpretation or a trim rule; "Work-only" is now the derived+  predicate `Site.isWorkOnlyTitleRule`. Nothing can reintroduce the fork by+  writing a column.+- **Derivation is centralised.** New rule forms extend one function and one+  validator table, rather than N call sites. The parity-audit tables in+  `design.md` list exactly what a form extension touches.+- **The extension boundary is now explicit.** The extension never migrates and+  fails closed until the readiness marker exists; it validates on open. That+  contract is tested per bootstrap state.++### Potential Issues++- **Validator cost on the open path.** `V4LibraryValidator` replays rule+  extraction per Entry on every app launch and every extension open. It is+  budgeted (p95 ≤ 1 s against the 5,000-Entry fixture) but it scales with library+  size, and the extension has a hard system time limit. Worth monitoring as+  libraries grow.+- **Preview cost scales with Work count.** The 5,000-Entry budget fixture is+  built with `works: []`, so the Work-matching paths in the projection planner+  are not exercised by the budget test. Re-teaching a site with many Works is the+  untested-for-performance case.+- **v3 key recompute is a whole-site rewrite.** Re-teaching a title rule on a+  sequence-only site rewrites every Entry's key in one commit. Correct, but the+  cost is linear in site size and happens inside a single transaction.+- **`URLRulePattern.definition` fails open.** It returns a hardcoded fallback+  rule when JSON decoding fails, rather than surfacing the corruption. Pre-M4+  behaviour, but it now feeds the validator's replay comparison.++---++## Completeness Assessment++### Fully implemented++- **Req 1 (per-field model)** — closed tuple set enumerated in `design.md` and+  enforced by `V4LibraryValidator`; no persisted interpretation column;+  canonicalising comparator prevents no-op re-versioning.+- **Req 2 (chapter-less title rules)** — chapter-less segment/phrase and+  whole-title forms; per-commit acknowledgment, not persisted state.+- **Req 3 (trims as part of title rules)** — trims live on `TitlePattern`, apply+  before parse/naming, fail open to the untrimmed title.+- **Req 4 (sequence-only URL rules)** — `.sequence` arm plus key v3 with the+  name discriminator, coexisting with v1/v2.+- **Req 5 (migration)** — `[V3,V4]` plan, durable checksummed sidecar,+  idempotent completion pass, full bootstrap state table, readiness gating across+  the process boundary, `3/3` and `2/2` import mapping.+- **Req 6 (capture-time rule application)** — the M3 gap is closed; capture,+  teaching, and lookup share `ComposedDeriver`; pre-insert re-lookup guards the+  race.+- **Req 9 (validation discipline)** — prospective-graph validation before each+  previewed commit, typed projection refusals, per-site quarantine that does not+  block launch, export refusal under quarantine.++### Implemented with gaps++- **Req 7.3 / 8 (reachable surfaces, UI test coverage).** The composed surface,+  Review URL identity, and Recalculate are reachable and covered by simulator UI+  tests that navigate from launch. However, roughly half of the UI deliverables+  listed in `design.md`'s testing strategy have **no** simulator UI test:+  whole-title rule commit, the live name preview element, disclosure+  auto-expansion when a URL rule already exists, collapsed-summary retention, and+  all three share-extension flows (re-share edit, ambiguous, new-capture). The+  extension flows are covered only by view-model unit tests, which Req 7.3+  explicitly excludes from counting.+- **Combined-template restoration.** Re-teaching a `.combined` URL rule restores+  the Work component chip but not the within-component split selection: a derived+  template does not identify a unique character selection.+- **`.chapterlessPhrase` is valid but unauthorable.** The closed tuple set admits+  a chapter-less phrase rule, but no gesture produces one — subdivided parts with+  only the Work marked author `.wholeTitle` + trims instead. Req 1.2 forbids+  valid-but-unreachable states, so either the arm or the tuple-set wording needs+  to change.++### Not implemented (out of scope by design)++Rule *removal* (returning a site to fewer sources) is replacement-only, as in M3.+Chapter-only title rules, persisted per-field source overrides, canonical URLs,+URL-rule inference, and regular expressions all remain excluded.++### Corrections applied during pre-push review++Four defects were found and fixed on this branch before push; see the review+findings section of `pre-push-review.html`. In short: re-teaching silently+replaced a site's retained title rule and could narrow its URL rule; two+fail-open `try?` sites could make a commit drop Works or a recalculation report+"no changes" without checking; capture matched Work titles by canonical+equivalence while teaching matched by exact scalars, violating Req 6.3; and+Articles mode was reachable in the UI but could never commit.++---++## Addendum: teaching-surface redesign (Decision 8)++After the six phases above, the teaching surface was redesigned because it still+asked the reader to classify their Site into the rule taxonomy before selecting+anything, and carried four modes' worth of controls.++**What changed.** The title-mode picker is deleted. The title renders as its+delimiter-split segments; tapping cycles a segment's role; an already-selected+multi-part segment offers an inline control that splits it into parts in the same+chip row. The rule form is inferred from the selection (Req 8.6) rather than+named. Character-level boundary controls are removed. Articles is reclassified as+the one-way Site transition it always was, with its own separated affordance.++**Why it was cheap.** `.phrase(prefix:separator:suffix:order:)` already means+"exact literals around and between two fields", so subdivided selection derives an+existing `PatternDefinition` arm through `PhrasePatternDeriver`. No new rule form,+capability gate, validator tuple-table, or backup wire-format change; AsterismCore+was untouched.++**Known limitation introduced by making phrase rules easier to reach.** A+`.phrase` separator must contain a non-whitespace scalar. Parts are maximal+alphanumeric runs, so a title like `Some Story 12` cannot source its chapter from+the title — the separator would be a bare space. The role is skipped in the tap+cycle and the chapter must come from a URL sequence, which the surface now+surfaces automatically. Whether the skip should explain itself is open.
specs/unified-teaching-composition/requirements.md Modified +15 / -3
diff --git a/specs/unified-teaching-composition/requirements.md b/specs/unified-teaching-composition/requirements.mdindex f4de8a3..9a1c60a 100644--- a/specs/unified-teaching-composition/requirements.md+++ b/specs/unified-teaching-composition/requirements.md@@ -60,7 +60,7 @@ Motivating cases from M3 testing, all of which MUST be teachable end to end:  1. <a name="3.1"></a>Exact leading/trailing affix trims SHALL be an optional property of the title rule, applied before segment/phrase parsing and before whole-title naming; M3's site-level `WorkTitleTrimRule` is absorbed and SHALL NOT remain a separate persisted mechanism after migration. 2. <a name="3.2"></a>Trim application SHALL fail open to the untrimmed title exactly as in M3 Decision 23, and the immutable capture title SHALL remain untouched evidence.-3. <a name="3.3"></a>Teaching SHALL let the reader select the kept span of the example title (token-level selection at minimum) and SHALL preview the resulting Work name before commit; keeping the entire title authors a whole-title rule with no trims, and a narrower kept span authors the corresponding leading/trailing trims.+3. <a name="3.3"></a>Teaching SHALL let the reader mark which spans of the example title supply the Work name and (optionally) the chapter, and SHALL preview the resulting Work name before commit. Selection is by tapping title chips, at two granularities: whole delimiter-split **segments** by default, and finer **parts** within a segment once it is subdivided ([8.3](#8.3)). Marking a Work span narrower than the whole title authors the corresponding trims or literal affixes, per the form inferred in [8.6](#8.6); marking the entire title as the Work authors a whole-title rule with no trims. (Amended: the original kept-span-only formulation is superseded by Decision 8.)  ### 4. Sequence-Only URL Rules @@ -112,10 +112,22 @@ Motivating cases from M3 testing, all of which MUST be teachable end to end: **Acceptance Criteria:**  1. <a name="8.1"></a>One teaching surface SHALL encompass title and URL teaching — the reader composes sources by making title and URL selections, each field resolving per the fixed field source resolution — with a single live preview of names, identities, sequences, and conflicts before one commit; composing both sources SHALL NOT require navigating between screens or sheets. The surface SHALL be reachable from the existing teaching entry points (Entry detail Teach/Re-teach and the Recent list's Teach/Re-teach affordances), which replace their M3 destinations with it.-2. <a name="8.2"></a>Title teaching SHALL be the surface's primary, initially visible content; the URL details (example URL and its selection controls) SHALL be collapsed by default behind an inline disclosure that is always available and labeled by benefit (working copy: "Add URL details"; final copy set by the visual design pass in [8.4](#8.4)) and SHALL expand in place; WHEN the current selection leaves a field unsourced, the disclosure SHALL additionally hint that the title is missing details. The URL details SHALL start expanded WHEN the Site already holds a current URL rule and WHEN teaching is entered from a URL-focused context (Review URL identity, recalculation, a URL-conflict state); WHEN the current selection leaves the Site without a chapter source, the surface SHALL point to the URL details as the remedy before offering the unsettled-chapters acknowledgment of [2.1](#2.1). Collapsing the disclosure is presentation-only: existing URL selections SHALL remain part of the preview and the commit, SHALL be summarized while collapsed, and SHALL be removable only by an explicit clear.-3. <a name="8.3"></a>Token/chip selection SHALL be the primary interaction for titles, path components, and within-component splits, with the M3 character-level boundary controls retained for sub-token precision; the M3 Work-only route, the separate URL-teaching sheet with its dismiss-then-present handoff, and the URL-teaching work-name section are replaced by this surface. The post-commit Work landing-URL confirmation queue is retained downstream of commit, and the M3 landing-URL candidate offer moves wholly into it (amending M3 5.1's placement in the URL-teaching preview).+2. <a name="8.2"></a>Title teaching SHALL be the surface's primary, initially visible content; the URL details (example URL and its selection controls) SHALL be collapsed by default behind an inline disclosure that is always available and labeled by benefit (working copy: "Add URL details"; final copy set by the visual design pass in [8.4](#8.4)) and SHALL expand in place; WHEN the current selection leaves a field unsourced, the disclosure SHALL additionally hint that the title is missing details. The URL details SHALL start expanded WHEN the Site already holds a current URL rule and WHEN teaching is entered from a URL-focused context (Review URL identity, recalculation, a URL-conflict state). WHEN the current title selection leaves the chapter unsourced, the URL details SHALL expand automatically and identify themselves as the remedy, before the unsettled-chapters acknowledgment of [2.1](#2.1) is offered — a passive hint alongside a collapsed disclosure does not satisfy this (amended by Decision 8; the acknowledgment remains the escape hatch for a Site that genuinely has no chapter anywhere). Collapsing the disclosure is presentation-only: existing URL selections SHALL remain part of the preview and the commit, SHALL be summarized while collapsed, and SHALL be removable only by an explicit clear.+3. <a name="8.3"></a>Chip selection SHALL be the only selection interaction for titles, path components, and within-component splits. For titles it operates at two granularities: the example title renders as its delimiter-split **segments**, and an already-selected segment SHALL offer an inline subdivide control that splits that segment in place into its constituent **parts** (maximal alphanumeric runs), which then become individually selectable. Subdivision SHALL happen inline in the same chip row — it SHALL NOT open a sheet, a second screen, or any navigation step. (Amended per Q30: subdivision is a distinct control on the chip rather than a repeat tap, because the segment tap is already a three-role cycle.) The M3 character-level boundary controls are **removed**, not retained (amending this criterion's original wording; see Decision 8 for the accepted loss of sub-part precision and its remedy). The M3 Work-only route, the separate URL-teaching sheet with its dismiss-then-present handoff, and the URL-teaching work-name section are replaced by this surface. The post-commit Work landing-URL confirmation queue is retained downstream of commit, and the M3 landing-URL candidate offer moves wholly into it (amending M3 5.1's placement in the URL-teaching preview). 4. <a name="8.4"></a>This milestone SHALL include the visual design pass M3 deferred: layout, hierarchy, and copy for the teaching surface meeting the existing accessibility contracts (labels, 44pt targets, Dynamic Type, non-color indicators); the collapsed/expanded state of the URL disclosure SHALL be conveyed by a non-color indicator. 5. <a name="8.5"></a>The M3 preview performance budgets and generation-gated publication SHALL apply unchanged to the composed preview (edit acknowledgement p95 ≤ 100 ms; complete 5,000-Entry preview p95 ≤ 1 s), including while the URL details are expanded; the 5,000-Entry fixture SHALL be re-specified to exercise composed Sites (per-Entry affix trim, title parse, and URL extraction together) so the budget measures the composed workload.+6. <a name="8.6"></a>The title rule form SHALL be **inferred from what the reader selected**, never chosen by the reader: the surface SHALL NOT present a title-mode picker or otherwise require the reader to name a rule form before selecting. The inference is fixed and total over the selectable states:++   | Selection | Authored form |+   |---|---|+   | Whole segments; Work and chapter both marked | `.segment` |+   | Whole segments; Work only | `.chapterlessSegment` |+   | Subdivided parts; Work and chapter both marked | `.phrase` |+   | Subdivided parts; Work only | `.wholeTitle` + trims |+   | Entire title marked as Work | `.wholeTitle`, no trims |++   No new `PatternDefinition` arm is introduced — the table maps onto the closed set of [1.1](#1.1) as it stands. The surface SHALL prevent selections that cannot author a legal rule rather than accepting them and reporting a validation error afterwards. The largest such class is a `.phrase` whose separator would be whitespace-only: parts are maximal alphanumeric runs, so marking the Work and chapter on parts separated by nothing but spaces (`Some Story 12`) yields a blank separator that `PhrasePatternDeriver` rejects. Such a Site cannot source its chapter from the title and MUST take it from a URL sequence instead, which [8.2](#8.2) surfaces. WHERE a role the reader asked for is skipped for this reason, the surface SHOULD say why rather than silently advancing to the next role. Segment-granularity selection SHALL remain the default and the path of least resistance, because positional `.segment` rules survive title variation that exact-literal `.phrase` rules do not.+7. <a name="8.7"></a>Articles mode is **not** part of the title-selection flow: it is a one-way Site transition that retires the active title rule and the current URL rule, not a title rule form, and it commits through `commitArticles` rather than the composed commit. Removing the title-mode picker per [8.6](#8.6) SHALL NOT leave it unreachable — it SHALL retain a distinct affordance on the surface, visually separated from title selection and carrying its own confirmation of the consequences. Its interaction design is otherwise unchanged by Decision 8.  ### 9. Validation and Test Discipline Carried Forward 
specs/unified-teaching-composition/tasks.md Modified +36 / -0
diff --git a/specs/unified-teaching-composition/tasks.md b/specs/unified-teaching-composition/tasks.mdindex 9cbc5dd..34f5d87 100644--- a/specs/unified-teaching-composition/tasks.md+++ b/specs/unified-teaching-composition/tasks.md@@ -279,3 +279,39 @@ references:   - Also absorbs the stream-4/5 deferrals: switch the app + extension runtime to openV4ForApp/ForExtension (.current is already .m4); promote planV4/BackupV4Exporter to the primary import/export path, retiring the V3 plan/BackupExporter; rewrite the ~11 live readers of titleInterpretation/workTitleTrimRule onto the active title rule; preserve the frozen 3/3 format. This is what makes the composed teaching surface functional in production, so it is the milestone's critical capstone, not cleanup.   - Blocked-by: yprxtd9 (Composed teaching projection), yprxtdc (Composed teaching commit), yprxtdf (Recalculation operations), yprxtdi (Quarantine scoping), yprxtdl (Capture-time rule application and lookup), yprxtdo (Backup V4 format), yprxtdr (Import mapping and version dispatch)   - Stream: 1++## Teaching Surface Redesign (Decision 8)++- [x] 26. Two-granularity title chip selection with in-place subdivision <!-- id:yprxte4 -->+  - Render the example title as delimiter-split segments (DelimiterTokenizer). Tapping cycles a segment role via the existing cycleRole; tapping an already-selected segment subdivides it in place into its parts (ComposedTeachingPresentation.tokenRanges), which become individually selectable in the same chip row. No sheet, no second screen. Live 'Work will be named' row throughout. Prevent selections that cannot author a legal rule (adjacent part spans yield the blank separator .phrase rejects) rather than surfacing a validation error afterwards. Files: ComposedTeachingView.swift, ComposedTeachingViewModel.swift+  - Stream: 1+  - Requirements: [3.3](requirements.md#3.3), [8.3](requirements.md#8.3)++- [x] 27. Infer the title rule form from the selection; delete the title-mode picker <!-- id:yprxte5 -->+  - Delete TitleMode, selectTitleMode, the mode picker, and the separate keptSpan/phrase/segment editor branches. buildTitleDefinition derives the form from the selection per the Req 8.6 table: whole segments + chapter to .segment; whole segments Work-only to .chapterlessSegment; parts + chapter to .phrase (via PhrasePatternDeriver.derive); parts Work-only to .wholeTitle + trims; entire title to .wholeTitle no trims. No new PatternDefinition arm and no AsterismCore change. Keep the retainedTitleRule/titleEdited re-teach behaviour intact and re-seed it from the new selection model.+  - Blocked-by: yprxte4 (Two-granularity title chip selection with in-place subdivision)+  - Stream: 1+  - Requirements: [8.6](requirements.md#8.6)++- [x] 28. Remove the character-level boundary controls <!-- id:yprxte6 -->+  - Delete charBoundaryControls, boundaryStepper, adjustKeptSpanStart/End, and adjustPhraseBoundary plus the composed-title-start/end accessibility identifiers. Accepted loss per Decision 8: a boundary inside an alphanumeric run is no longer expressible from the title; the URL sequence source is the remedy and task 29 surfaces it. Confirm the URL editor is unaffected (it never had boundary controls).+  - Blocked-by: yprxte4 (Two-granularity title chip selection with in-place subdivision)+  - Stream: 1+  - Requirements: [8.3](requirements.md#8.3)++- [x] 29. Auto-expand URL details when the chapter is unsourced <!-- id:yprxte7 -->+  - When the title selection leaves the chapter unsourced, expand the URL disclosure automatically and label it as the remedy, instead of the current passive missingDetailsHint beside a collapsed disclosure. Preserve the existing auto-expand triggers (Site already holds a URL rule; URL-focused entry context) and Q11 collapse-is-presentation-only semantics. The unsettled-chapters acknowledgment stays available for Sites with no chapter anywhere.+  - Stream: 1+  - Requirements: [8.2](requirements.md#8.2)++- [x] 30. Separate the Articles affordance from title selection <!-- id:yprxte8 -->+  - Removing the mode picker deletes Articles' only entry point, so it must land with task 27 or Articles regresses to the dead code the pre-push review just fixed. Give it a distinct affordance visually separated from title selection, with its own confirmation of consequences (retires the active title rule and the current URL rule). It keeps committing through projectArticles/commitArticles, never the composed commit. Interaction design otherwise unchanged.+  - Blocked-by: yprxte5 (Infer the title rule form from the selection; delete the title-mode picker)+  - Stream: 1+  - Requirements: [8.7](requirements.md#8.7)++- [x] 31. Rewrite composed-surface UI tests for the redesigned flow <!-- id:yprxte9 -->+  - Rewrite rather than extend: the shipped tests drive the mode picker (composed-mode-whole-title/segments/phrase/articles) and the kept-span interaction, which no longer exist. Real navigation from launch per Req 7.3. Cover segment selection to committed .segment rule; subdivide-in-place to committed .phrase rule; Work-only selection to .chapterlessSegment; chapter-unsourced auto-expanding the URL details; the separated Articles affordance and its confirmation. Also close the pre-existing Req 7.3 gaps this touches: assert the live Work-name preview element and the collapsed-summary retention rather than discarding waitForExistence results.+  - Blocked-by: yprxte4 (Two-granularity title chip selection with in-place subdivision), yprxte5 (Infer the title rule form from the selection; delete the title-mode picker), yprxte6 (Remove the character-level boundary controls), yprxte7 (Auto-expand URL details when the chapter is unsourced), yprxte8 (Separate the Articles affordance from title selection)+  - Stream: 1+  - Requirements: [7.3](requirements.md#7.3), [8.2](requirements.md#8.2), [8.3](requirements.md#8.3), [8.6](requirements.md#8.6), [8.7](requirements.md#8.7)

Things to double-check

The split editor on a real device.

The fix is covered by a simulator UI test, but the split editor is the most fiddly interaction on the surface and it has been unusable until now — so it has never really been exercised by hand. Worth driving once on device against a /Story-28614-94/-shaped URL.

Titles shaped like <code>Some Story 12</code>.

These cannot source a chapter from the title, and today the tap is simply skipped with no explanation (T-1911). If such a site is in the real library, it is worth seeing what the flow actually feels like before deciding how much the explanation matters.

First teach of an untaught Site.

The URL details now expand immediately (T-1912). Worth judging on device whether that reads as helpful or as the wall of controls Decision 1 wanted to avoid — that judgement should settle the ticket.