asterism branch T-2317/share-sheet-character-chips commits 10 files 20 touched lines +1650 / -194

Pre-push review: T-2317/share-sheet-character-chips

The share sheets' cast text row becomes name-only chips with an inline detail card, capped at two rows behind a +N more chip.

At a glance

  • Both capture sheets draw the cast as name-only chips; tapping one opens an inline card with aliases and the whole note. No facts reach the extension.
  • A cast past two chip rows collapses behind +N more; hidden chips are not rendered, so VoiceOver and taps cannot reach them (Decision 1, superseding Q2).
  • FlowLayout moved from the app target into ConstellationKit and now clamps an item wider than its row; fitting content is laid out as before.
  • Every rule and VoiceOver string is a tested pure value in AsterismCore; the view holds state and measurements only.
  • Read-only change: no schema, archive or CloudKit impact, no extra fetch in the share read.

Verdict

Ready to push

make test-core (zero issues) and make test-quick including the Mac build pass on the final tree, rebased onto 8430226f (#78). No major code findings; two stale docs and five minor code issues were fixed in 3d360f4f. Four UI tests fail on make test-ui / make test-ui-ipad; the same four were reproduced failing on main without this change, and none touches the share extension. No structured test data: the Swift package is not at the repo root and the Makefile emits no JUnit.

Review findings

12 raised · 8 fixed · 4 skipped

Jump to findings →

Tests

Pass rate: n/a

New tests: 25

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What changed

The share sheet used to list a work's characters as one line of text. It now shows each character as a small button with the main name. Tap one and a card opens underneath with that character's other names and the note you wrote about them. A long cast shows two rows and a +12 more button.

Why it matters

The share sheet is where a reader writes a chapter note. They can now check what they wrote about a character without leaving it, and a long cast no longer pushes the note field off screen.

Key concepts

  • Share extension: the small Asterism window inside another app's Share menu; a separate, memory-limited program.
  • Chip: a pill-shaped button with a short label.
  • Wrapping layout: chips flow left to right and start a new row when they run out of room.
  • VoiceOver: the screen reader. Hidden chips are truly absent, so it cannot land on something invisible.

Changes overview

  • ShareWorkContext.swift: ShareCharacter is Identifiable with a required id and a trimmed note; ShareCharacterChips replaces ShareCharacterRow and owns every rule and string.
  • FlowLayout.swift: public in ConstellationKit, with pure arrangement and visibleCount functions and a clamp for an oversized subview.
  • ShareCharacterChipsView.swift: replaces ShareCharactersRow.swift on both sheets.

Approach

Views lay out, core decides. The view's state is openID, isExpanded and three measurements; every transition goes through a unit-tested function. A hidden measuring copy of the cast in the row's background supplies chip widths, and visibleCount lays candidate rows out with the same arithmetic the real layout uses, so the count and the drawn row cannot disagree.

Trade-offs

Clipping a full row was simpler but leaves hidden chips in the accessibility tree. A row limit inside the Layout cannot report how many it hid. The first frame draws no chips rather than flashing the full cast.

Deep dive

  • Identity: openID is the record group's UUID, so same-named characters stay distinct and an identical re-read compares equal, keeping CaptureViewModel's change check stable.
  • visibleCount: walks k upward while first k + expander fits; adding a leading item never removes a row under greedy wrapping, so the first failure is final and cost follows what two rows hold.
  • Clamp: only a subview whose ideal width exceeds the row is re-measured and placed with a width proposal; everything else keeps .unspecified, which the work page's meta line depends on.
  • Reflow: onChange(of: collapsedCount) re-derives openID and resets isExpanded once the cast fits (Q16).

Architecture impact

FlowLayout is now ConstellationKit API; the app's fifteen uses across eight views are untouched for fitting content. The extension gains no dependency and still does not link AsterismIntelligence.

Potential issues

The extension has no XCUITest coverage by decision, so rendered layout rests on pure tests plus a device look. An app Label wider than its whole row is now clamped where it used to overflow. A refresh that adds one character blanks the row for a pass until the new chip is measured.

Important changes — detailed

ShareCharacterChipsView: the chips, the card and the two-row cap

Asterism/AsterismShareExtension/ShareCharacterChipsView.swift

Why it matters. All user-visible behaviour lives here, and the extension has no UI test coverage, so this is the file a reviewer has to read.

What to look at. body, drawn / collapsedCount, measuringCopies, pillLabel

Takeaway. To cap a wrapping row without leaving hidden items in the accessibility tree: measure a hidden, non-hit-testable copy in the background and draw only the prefix a pure function says fits.
Rationale. Decision 1: clipping keeps hidden chips speakable and tappable, and a Layout cannot tell its view how many items it hid.

FlowLayout.visibleCount and arrangement as pure functions

Packages/AsterismCore/Sources/ConstellationKit/FlowLayout.swift

Why it matters. New public API in ConstellationKit, used by fifteen app call sites; the clamp changes how an oversized item lays out.

What to look at. arrangement(of:rowWidth:spacing:), visibleCount(...), measured(_:rowWidth:)

Takeaway. Pull a Layout's arithmetic into a static function of sizes so it can be unit tested without rendering, and answer derived questions by laying candidates out with it rather than re-deriving the wrap rule.
Rationale. Q3, Q10 and Decision 1: one copy shared with the extension; only the oversized subview hears about the row width because a Label given a width can resolve to its icon alone.

ShareCharacter gains a required id and a trimmed note

Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift

Why it matters. Changes a value that crosses from the repository into the extension; the id has no default so the compiler found every fixture.

What to look at. ShareCharacter, the characters map in the read, ShareCharacterChips

Takeaway. Give a new required field no default when you want the compiler to enumerate every construction site for you.
Rationale. Q6: store-derived ids keep the view model's equality check stable and let an open card survive a refresh.

Reflow keeps the open chip and expanded flag honest

Asterism/AsterismShareExtension/ShareCharacterChipsView.swift

Why it matters. Added in this review: without it a rotation or text-size change could hide an open card and bring it back unasked.

What to look at. onChange(of: collapsedCount)

Takeaway. When derived layout can change state's meaning, re-derive the state through the same tested rule the tap uses.
Rationale. Q16.

Testing note: check the login keychain before the screen lock

docs/agent-notes/testing.md

Why it matters. Corrects a standing diagnosis that cost earlier sessions hours: the 121-issue spool signature followed the keychain, not the screen.

What to look at. the 2026-09-19 paragraphs at the top of the locked-screen section

Takeaway. CodeSign errSecInternalComponent is the cheap tell for a locked login keychain; security show-keychain-info does not detect it.
Rationale. Observed once, cleanly: only the keychain was unlocked and both codesign and the spool suites recovered under a locked screen.

Key decisions

Decision 1: cap the collapsed cast at two chip rows.

Supersedes Q2's acceptance of the height after the first run on a phone. Rejected: clip-and-expand, a Layout-side row limit, a Show all button without a count, a scrolling single row.

Q14: hit target in both directions.

The pill recipe only sets a minimum height; the chip label adds a leading-aligned minimum width.

Q15: <code>.count</code> pills and fixed-width digits for the expander.

Room is measured at the cast's full count; proportional digits could make a smaller number wider than the room kept.

Q16: state follows what is drawn on reflow.

openID is re-derived and isExpanded resets once the cast fits.

Rebased onto #78 before review.

origin/main gained the fix for the database is locked store-metadata failures during the session; rebasing the unpushed branch let verification run clean.

Review findings

SeverityAreaFindingResolution
majordocs/agent-notes/testing.mdThe 2026-09-19 note called the store-metadata failures unexplained while #78, now an ancestor, explains and fixes them in the same file.Paragraph rewritten to point at the busy-timeout section and record the clean run.
majorspecs/OVERVIEW.mdThe spec's section still read Planned, nothing implemented, and described the height as accepted.Status, body and table summary updated; implementation.md listed.
minorShareCharacterChipsView expander widthRoom for +N more was measured at the full count with proportional digits, so a smaller drawn number could be wider and wrap to a third row.monospacedDigit() on every chip label; Q15.
minorShareCharacterChipsView stateopenID and isExpanded outlived what was drawn after a rotation, text-size change or refresh.onChange(of: collapsedCount) re-derives both; Q16.
minorFlowLayout.visibleCount costWalked down from n-1 with an allocation per step, and the view evaluated it up to four times per body.Walks up and stops at the first miss; drawn bound once per body.
minorChip hit targetA two-letter name produced a chip narrower than minHitTarget.Leading-aligned minWidth frame and contentShape on the chip label; Q14.
minorTestsNo case for a whitespace-only note, a multi-line note, an expander-only answer or a very long cast.Four cases added.
nitCommentsRecordRanking said chips show aliases and notes; a Q14 reference named no log; chipSizes comment claimed entries are dropped; eight call sites was fifteen.All four corrected.
minorRefresh adding a charactercollapsedCount is nil for one pass until the new chip is measured, so the row and an open card blink out (speculative).The share read arrives as one batch; left alone and listed under double-check.
minorFlowLayout placeSubviews widthClamps against bounds.width while sizeThatFits clamps against proposal.width (speculative).Harmless for the clamped item, whose reported width is the row width.
nitView duplicationTwo trailing-chip builders and two DEBUG initialisers differ only slightly; identifierPrefix is a free string.Cosmetic; left as is.
nitReuseInline pluralisation, trimming and geometry measurement all follow existing package conventions; the only real duplicate is private to the app target.No change.

Tests

Source: local run at 2026-09-19T14:35:00+10:00 · snapshot 3d360f4fbb1135f5351a7dd297f6fbe6c357c05e

Baseline: none

Execution: passed · JUnit: none · Coverage: none · Baseline: absent

Coverage scope: as the project configures it

No test results

The test runner could not be detected.

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 8430226f2b37c82f3c5ccf74318aa3d5148d1e97.

Dependents none found Changed Dependencies none found . Asterism/Asterism/Views …terism/AsterismShareExtension …rismCore/Sources/AsterismCore …Core/Sources/ConstellationKit …mCore/Tests/AsterismCoreTests …e/Tests/ConstellationKitTests docs/agent-notes specs …s/share-sheet-character-chips CHANGELOG.mdCHANGELOG.md Asterism/Asterism/Views/TeachingComponents.swift…iews/TeachingComponents.swift Asterism/AsterismShareExtension/CaptureView.swift…reExtension/CaptureView.swift Asterism/AsterismShareExtension/ReShareCaptureView.swift…sion/ReShareCaptureView.swift Asterism/AsterismShareExtension/ShareCatchUpSection.swift…ion/ShareCatchUpSection.swift Asterism/AsterismShareExtension/ShareCharacterChipsView.swift…ShareCharacterChipsView.swift Asterism/AsterismShareExtension/ShareCharactersRow.swift…sion/ShareCharactersRow.swift Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swift…erismCore/RecordRanking.swift Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift…smCore/ShareWorkContext.swift Packages/AsterismCore/Sources/ConstellationKit/FlowLayout.swift…tellationKit/FlowLayout.swift Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift…Tests/CaptureStateTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift…ReShareExtensionUITests.swift Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift…s/ShareWorkContextTests.swift Packages/AsterismCore/Tests/ConstellationKitTests/FlowLayoutTests.swift…itTests/FlowLayoutTests.swift docs/agent-notes/testing.mddocs/agent-notes/testing.md specs/OVERVIEW.mdspecs/OVERVIEW.md specs/share-sheet-character-chips/decision_log.md…aracter-chips/decision_log.md specs/share-sheet-character-chips/implementation.md…acter-chips/implementation.md specs/share-sheet-character-chips/smolspec.md…t-character-chips/smolspec.md specs/share-sheet-character-chips/tasks.md…heet-character-chips/tasks.md
addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Per-file diffs

Click to expand.

Asterism/Asterism/Views/TeachingComponents.swift Modified +0 / -44
diff --git a/Asterism/Asterism/Views/TeachingComponents.swift b/Asterism/Asterism/Views/TeachingComponents.swiftindex e55cfa40..89ec2c27 100644--- a/Asterism/Asterism/Views/TeachingComponents.swift+++ b/Asterism/Asterism/Views/TeachingComponents.swift@@ -94,47 +94,3 @@ struct TitleChipView: View {         }     } }--/// Simple wrapping layout for chips.-struct FlowLayout: Layout {-    var spacing: CGFloat = 8--    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {-        arrange(proposal: proposal, subviews: subviews).size-    }--    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {-        let result = arrange(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)-        for (index, position) in result.positions.enumerated() {-            subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)-        }-    }--    private struct ArrangeResult {-        var size: CGSize-        var positions: [CGPoint]-    }--    private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> ArrangeResult {-        let maxWidth = proposal.width ?? .infinity-        var positions: [CGPoint] = []-        var x: CGFloat = 0-        var y: CGFloat = 0-        var rowHeight: CGFloat = 0-        var totalWidth: CGFloat = 0--        for subview in subviews {-            let size = subview.sizeThatFits(.unspecified)-            if x + size.width > maxWidth, x > 0 {-                x = 0-                y += rowHeight + spacing-                rowHeight = 0-            }-            positions.append(CGPoint(x: x, y: y))-            rowHeight = max(rowHeight, size.height)-            x += size.width + spacing-            totalWidth = max(totalWidth, x - spacing)-        }-        return ArrangeResult(size: CGSize(width: totalWidth, height: y + rowHeight), positions: positions)-    }-}
Asterism/AsterismShareExtension/CaptureView.swift Modified +14 / -8
diff --git a/Asterism/AsterismShareExtension/CaptureView.swift b/Asterism/AsterismShareExtension/CaptureView.swiftindex 3e075a01..d2b71f78 100644--- a/Asterism/AsterismShareExtension/CaptureView.swift+++ b/Asterism/AsterismShareExtension/CaptureView.swift@@ -307,14 +307,15 @@ struct CaptureView: View {                 .accessibilityIdentifier("capture.noChapter")             } -            // The projected work's cast (T-1916, Q6). One wrapping text row,-            // filled in a refresh after the sheet appears; nothing renders for-            // a work without characters, for a projection that names no-            // existing work, or for a failed read — the row text is nil for an-            // empty list in all three cases.-            if let charactersText = ShareCharacterRow.text(for: viewModel.workContext.characters) {-                ShareCharactersRow(-                    text: charactersText, accessibilityIdentifier: "capture.characters")+            // The projected work's cast (T-1916, T-2317). One chip per+            // character, filled in a refresh after the sheet appears; nothing+            // renders for a work without characters, for a projection that+            // names no existing work, or for a failed read — the list is empty+            // in all three cases.+            if !viewModel.workContext.characters.isEmpty {+                ShareCharacterChipsView(+                    characters: viewModel.workContext.characters,+                    identifierPrefix: "capture.characters")             }              // Actionable state indicator@@ -336,6 +337,11 @@ struct CaptureView: View {         .frame(maxWidth: .infinity, alignment: .leading)         .padding()         .constellationField()+        // Q12: the chips are the first controls inside this identified+        // container, and an identifier without `children: .contain` collapses+        // the whole card into one element+        // (`docs/agent-notes/composed-teaching-ui.md`).+        .accessibilityElement(children: .contain)         .accessibilityIdentifier("capture.projectedMetadata")     } 
Asterism/AsterismShareExtension/ReShareCaptureView.swift Modified +7 / -6
diff --git a/Asterism/AsterismShareExtension/ReShareCaptureView.swift b/Asterism/AsterismShareExtension/ReShareCaptureView.swiftindex 882fd696..af681047 100644--- a/Asterism/AsterismShareExtension/ReShareCaptureView.swift+++ b/Asterism/AsterismShareExtension/ReShareCaptureView.swift@@ -127,12 +127,13 @@ struct ReShareCaptureView: View {         // Banner: "Noted <date> — editing existing entry"         editBanner(firstCapturedAt: state.firstCapturedAt) -        // The work's cast (T-1916, Q6). One wrapping text row; nothing renders-        // for a work without characters, which is also what a failed read-        // leaves behind — the row text is nil for an empty list.-        if let charactersText = ShareCharacterRow.text(for: state.workContext.characters) {-            ShareCharactersRow(-                text: charactersText, accessibilityIdentifier: "reshare.characters")+        // The work's cast (T-1916, T-2317). One chip per character; nothing+        // renders for a work without characters, which is also what a failed+        // read leaves behind — an empty list either way.+        if !state.workContext.characters.isEmpty {+            ShareCharacterChipsView(+                characters: state.workContext.characters,+                identifierPrefix: "reshare.characters")                 .frame(maxWidth: .infinity, alignment: .leading)         } 
Asterism/AsterismShareExtension/ShareCatchUpSection.swift Modified +3 / -2
diff --git a/Asterism/AsterismShareExtension/ShareCatchUpSection.swift b/Asterism/AsterismShareExtension/ShareCatchUpSection.swiftindex 0c9f87d9..d56ae449 100644--- a/Asterism/AsterismShareExtension/ShareCatchUpSection.swift+++ b/Asterism/AsterismShareExtension/ShareCatchUpSection.swift@@ -67,8 +67,9 @@ struct ShareCatchUpSection: View {     private func lastNoteBlock(_ note: ShareLastNote) -> some View {         VStack(alignment: .leading, spacing: 4) {             // `.firstTextBaseline`, so the glyph stays beside the heading's-            // first line when a long chapter title wraps — where-            // `ShareCharactersRow` puts its own.+            // first line when a long chapter title wraps — the same place+            // `ShareCharacterChipsView` keeps its own glyph, at the top of a+            // row that can grow.             HStack(alignment: .firstTextBaseline, spacing: 6) {                 if let direction = ratingDirection(note.rating) {                     Image(systemName: direction.symbolName)
Asterism/AsterismShareExtension/ShareCharacterChipsView.swift Added +473 / -0
diff --git a/Asterism/AsterismShareExtension/ShareCharacterChipsView.swift b/Asterism/AsterismShareExtension/ShareCharacterChipsView.swiftnew file mode 100644index 00000000..d0b9658c--- /dev/null+++ b/Asterism/AsterismShareExtension/ShareCharacterChipsView.swift@@ -0,0 +1,473 @@+import AsterismCore+import ConstellationKit+import SwiftUI++/// The work's cast, as one chip per character with the open one's detail card+/// beneath the row (T-2317, Q2, Q4) — where both capture sheets drew a single+/// text row before.+///+/// A chip carries the main name only; the aliases and the note the reader wrote+/// are inside the card, which opens on a tap and closes on the same tap. One+/// card at a time, none open when the sheet appears. No facts: a fact cites an+/// entry anywhere in the work and the sheet has no spoiler boundary (Q1).+///+/// A cast past two chip rows is drawn to two, with a `+N more` chip ending the+/// second (Decision 1): a long cast pushed the note editor off the sheet before+/// the reader had typed anything. The names behind the expander are not drawn at+/// all — not drawn and clipped — so VoiceOver does not reach them and a tap+/// cannot hit them.+///+/// The view decides nothing. Which chip is open after a tap, which character+/// that id names, how many chips two rows hold and every string VoiceOver reads+/// are `ShareCharacterChips`'s and `FlowLayout`'s (Q9); the caller decides+/// whether the row exists at all, because a work with no characters and a failed+/// read both arrive as an empty list.+struct ShareCharacterChipsView: View {+    let characters: [ShareCharacter]+    /// `"capture.characters"` or `"reshare.characters"` — the sheet this row+    /// belongs to, which prefixes the identifier on every chip and the card.+    let identifierPrefix: String++    /// Tracked by id rather than by position (Q6): two characters may share a+    /// name, and a refresh can move or drop one under an open card.+    @State private var openID: UUID?+    /// Never persisted and never carried across a presentation: the sheet opens+    /// collapsed every time (Decision 1).+    @State private var isExpanded = false+    /// What the measuring copies report, which is what decides how many chips+    /// two rows hold. Keyed by id, so a refresh that drops a character leaves an+    /// entry nothing reads; it goes with the sheet.+    @State private var chipSizes: [UUID: CGSize] = [:]+    @State private var expanderSize: CGSize = .zero+    @State private var rowWidth: CGFloat = 0+    @AccessibilityFocusState private var detailFocused: Bool++    private let chipSpacing: CGFloat = 8++    var body: some View {+        // Once per pass: it lays candidate rows out to answer.+        let drawn = self.drawn+        VStack(alignment: .leading, spacing: 8) {+            HStack(alignment: .top, spacing: 6) {+                Image(systemName: "person.2")+                    .font(.caption)+                    .foregroundStyle(.secondary)+                    .accessibilityHidden(true)+                chipRow(drawn)+            }+            // A refresh that drops the open character leaves no card open,+            // because the id names nobody in the new list — and a collapse that+            // takes the open chip away takes its card with it.+            if let open = ShareCharacterChips.openCharacter(openID, in: drawn?.characters ?? []) {+                detailCard(open)+            }+        }+        .frame(maxWidth: .infinity, alignment: .leading)+        // What two rows hold can change under the reader — a rotation, a text+        // size, a refresh. The open chip and the expanded flag follow what is+        // drawn, or a card comes back unasked on the next expand and a cast+        // that overflows again arrives already expanded.+        .onChange(of: collapsedCount) { _, newValue in+            guard let newValue else { return }+            if newValue >= characters.count {+                isExpanded = false+            } else if !isExpanded {+                openID = ShareCharacterChips.openAfterCollapse(+                    open: openID, visible: Array(characters.prefix(newValue)))+            }+        }+        // Req 8.1's pattern: the card renders after the whole chip row, so+        // without a focus move a VoiceOver reader swipes past every chip to+        // reach what their tap just opened (Q11).+        .onChange(of: openID) { _, newValue in+            guard newValue != nil else { return }+            detailFocused = true+        }+    }++    // MARK: - What gets drawn++    /// The trailing chip of the row, if it has one.+    private enum Trailing: Equatable {+        /// The cast fits the limit: no expander, and the row is what it was+        /// before this change.+        case none+        /// `N` names are not drawn.+        case more(Int)+        /// Everything is drawn, and this collapses it again.+        case fewer+    }++    /// The chips to draw and what ends the row — or nil for the frame before the+    /// measuring copies have reported, in which case nothing is drawn. An+    /// unmeasured first frame that drew the whole cast and then collapsed would+    /// be the flash this change exists to remove.+    private var drawn: (characters: [ShareCharacter], trailing: Trailing)? {+        guard let collapsedCount else { return nil }+        if collapsedCount >= characters.count { return (characters, .none) }+        if isExpanded { return (characters, .fewer) }+        return (+            Array(characters.prefix(collapsedCount)), .more(characters.count - collapsedCount)+        )+    }++    /// How many chips a collapsed row draws, with the room the expander needs+    /// already taken out of the last one — nil until everything is measured.+    private var collapsedCount: Int? {+        guard rowWidth > 0, expanderSize.width > 0 else { return nil }+        var sizes: [CGSize] = []+        for character in characters {+            guard let size = chipSizes[character.id] else { return nil }+            sizes.append(size)+        }+        return FlowLayout.visibleCount(+            of: sizes, expander: expanderSize, rowWidth: rowWidth, spacing: chipSpacing,+            rowLimit: ShareCharacterChips.visibleRowLimit)+    }++    private var collapsedCharacters: [ShareCharacter] {+        Array(characters.prefix(collapsedCount ?? 0))+    }++    // MARK: - The row++    /// The names, wrapping, to two rows unless the reader asked for the rest.+    /// `children: .contain` before the group label, or the label collapses the+    /// chips into one element and each stops being a separately reachable button+    /// (`docs/agent-notes/composed-teaching-ui.md`).+    private func chipRow(+        _ drawn: (characters: [ShareCharacter], trailing: Trailing)?+    ) -> some View {+        FlowLayout(spacing: chipSpacing) {+            if let drawn {+                ForEach(Array(drawn.characters.enumerated()), id: \.element.id) {+                    index, character in+                    chip(character, index: index)+                }+                switch drawn.trailing {+                case .none: EmptyView()+                case .more(let hidden): expanderChip(hidden: hidden)+                case .fewer: collapseChip+                }+            }+        }+        .frame(maxWidth: .infinity, alignment: .leading)+        .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { rowWidth = $0 }+        .background { measuringCopies }+        .accessibilityElement(children: .contain)+        .accessibilityLabel(ShareCharacterChips.groupLabel)+    }++    /// One name. `.plain`, as the work page's cast pills are: sibling buttons+    /// bleed their hit areas into each other under the default style.+    ///+    /// The identifier is indexed by display position (Q13) — a UUID in an+    /// identifier is unaddressable — and sits on the button, which is a leaf.+    private func chip(_ character: ShareCharacter, index: Int) -> some View {+        let isOpen = openID == character.id+        return Button {+            withAnimation(.snappy) {+                openID = ShareCharacterChips.toggled(open: openID, tapped: character.id)+            }+        } label: {+            // `work-detail-reading-redesign` Q14's recipe, as the work page+            // uses it: the open pill is the same violet, lifted, so the card+            // below is visibly its card.+            pillLabel(character.name, kind: isOpen ? .selectedTypeTag : .typeTag)+        }+        .buttonStyle(.plain)+        .accessibilityIdentifier("\(identifierPrefix).chip.\(index)")+        .accessibilityLabel(character.name)+        .accessibilityValue(ShareCharacterChips.chipValue(isOpen: isOpen))+    }++    /// The last chip of row two where the cast does not fit it. Cyan rather than+    /// violet: it names no character, and violet is what a name looks like here.+    private func expanderChip(hidden: Int) -> some View {+        Button {+            withAnimation(.snappy) { isExpanded = true }+        } label: {+            pillLabel(ShareCharacterChips.expanderText(hidden: hidden), kind: .count)+        }+        .buttonStyle(.plain)+        .accessibilityIdentifier("\(identifierPrefix).more")+        .accessibilityLabel(ShareCharacterChips.expanderLabel(hidden: hidden))+    }++    /// The last chip of an expanded cast. Collapsing can take the open chip+    /// away, and its card goes with it: a card under no chip cannot be closed by+    /// the tap that opened it.+    private var collapseChip: some View {+        Button {+            withAnimation(.snappy) {+                isExpanded = false+                openID = ShareCharacterChips.openAfterCollapse(+                    open: openID, visible: collapsedCharacters)+            }+        } label: {+            pillLabel(ShareCharacterChips.collapseText, kind: .count)+        }+        .buttonStyle(.plain)+        .accessibilityIdentifier("\(identifierPrefix).fewer")+        .accessibilityLabel(ShareCharacterChips.collapseLabel)+    }++    /// One line, truncated: a name wider than the row is kept inside it by+    /// `FlowLayout`, which proposes the row width to a subview that does not fit+    /// (Q10). The pill recipe carries `AsterismLayout.minHitTarget` as a height;+    /// the width is added here, because a two-letter name is a narrower pill+    /// than a thumb.+    ///+    /// Digits are fixed-width so that `+9 more` is never wider than the+    /// `+12 more` the row kept room for: SF's digits are proportional, and a+    /// one-point overrun is a third row.+    private func pillLabel(_ text: String, kind: ConstellationPillKind) -> some View {+        Text(text)+            .lineLimit(1)+            .truncationMode(.tail)+            .constellationPill(kind)+            .monospacedDigit()+            // Leading, so a short first name still starts on the row's edge.+            .frame(minWidth: AsterismLayout.minHitTarget, alignment: .leading)+            .contentShape(Rectangle())+    }++    // MARK: - Measurement++    /// The whole cast and the expander, laid out the way the row lays them out+    /// but never drawn, never spoken and never tappable.+    ///+    /// The row needs every chip's width to know how many of them two rows hold,+    /// and the chips it decides not to draw are exactly the ones it cannot+    /// measure from what it drew. A copy in the background is measured against+    /// the same row width and costs a layout pass; clipping a full row instead+    /// would leave the hidden names in the accessibility tree and under the+    /// reader's thumb, which is the thing to avoid.+    ///+    /// The open chip is not measured separately: `selectedTypeTag` is+    /// `typeTag`'s colours, with the same font, padding and minimum size.+    private var measuringCopies: some View {+        FlowLayout(spacing: chipSpacing) {+            ForEach(characters) { character in+                pillLabel(character.name, kind: .typeTag)+                    .onGeometryChange(for: CGSize.self) { $0.size } action: {+                        chipSizes[character.id] = $0+                    }+            }+            // `+N more` at the widest count this cast can produce, so the room+            // kept for the expander is never short of what it takes.+            pillLabel(+                ShareCharacterChips.expanderText(hidden: characters.count), kind: .count+            )+            .onGeometryChange(for: CGSize.self) { $0.size } action: { expanderSize = $0 }+        }+        .hidden()+        .accessibilityHidden(true)+        .allowsHitTesting(false)+    }++    // MARK: - The card++    /// The open character: the name, its aliases as chips, then the whole note.+    ///+    /// One combined element, because it holds no control — the work page's card+    /// does the same, minus the facts, the torn marker and the editing this+    /// sheet has none of.+    private func detailCard(_ character: ShareCharacter) -> some View {+        VStack(alignment: .leading, spacing: 10) {+            FlowLayout(spacing: chipSpacing) {+                Text(character.name)+                    .font(AsterismTypography.serifHeading)+                    .fixedSize(horizontal: false, vertical: true)+                ForEach(character.aliases, id: \.self) { alias in+                    Text(alias)+                        .constellationPill(.count)+                }+            }+            .frame(maxWidth: .infinity, alignment: .leading)++            if character.note.isEmpty && character.aliases.isEmpty {+                // Q5: every chip opens a card, so a character with nothing+                // behind it says so rather than coming up blank.+                Text(ShareCharacterChips.emptyDetailText)+                    .font(.subheadline)+                    .foregroundStyle(AsterismColors.secondaryText)+            }+            if !character.note.isEmpty {+                // The note arrives trimmed from the read and keeps its own line+                // breaks; nothing is clamped, as the work page shows it.+                Text(character.note)+                    .font(.subheadline)+                    .foregroundStyle(AsterismColors.noteText)+                    .lineSpacing(4)+                    .fixedSize(horizontal: false, vertical: true)+                    .frame(maxWidth: .infinity, alignment: .leading)+            }+        }+        .frame(maxWidth: .infinity, alignment: .leading)+        .padding(12)+        .constellationCard()+        .accessibilityElement(children: .combine)+        .accessibilityLabel(ShareCharacterChips.detailLabel(for: character))+        .accessibilityFocused($detailFocused)+        .accessibilityIdentifier("\(identifierPrefix).detail")+    }+}++// The share extension cannot be driven from an XCUITest (see+// `docs/agent-notes/composed-teaching-ui.md`), so these previews are how the+// layout gets looked at: a long cast collapsed to two rows and expanded, an open+// card with a multi-line note, a name wider than the row, and the largest+// accessibility text size.++#if DEBUG+extension ShareCharacterChipsView {+    /// Previews only. The sheet's own two call sites take the initialiser above,+    /// which opens with no card and collapsed — a preview cannot tap a chip.+    init(+        characters: [ShareCharacter], identifierPrefix: String, openFor id: UUID,+        expanded: Bool = false+    ) {+        self.characters = characters+        self.identifierPrefix = identifierPrefix+        _openID = State(initialValue: id)+        _isExpanded = State(initialValue: expanded)+    }++    /// Previews only: the expanded row with no card open.+    init(characters: [ShareCharacter], identifierPrefix: String, expanded: Bool) {+        self.characters = characters+        self.identifierPrefix = identifierPrefix+        _isExpanded = State(initialValue: expanded)+    }+}++private func previewCharacter(+    _ index: Int, _ name: String, aliases: [String] = [], note: String = ""+) -> ShareCharacter {+    ShareCharacter(+        id: UUID(uuidString: String(format: "2F000000-0000-4000-8000-%012X", index))!,+        name: name, aliases: aliases, note: note)+}++private let previewCast: [ShareCharacter] = [+    previewCharacter(+        1, "Kest", aliases: ["The Courier", "Kestrel"],+        note: """+            Carried the second letter the whole way and said nothing about it. \+            Reread her argument at the bridge once the harbour scene lands — it \+            is a different conversation the second time.++            Still owes the guild for the horse.+            """),+    previewCharacter(2, "Ivo Renn", aliases: ["Ren"]),+    previewCharacter(3, "The Tollkeeper", note: "Never named. Probably deliberate."),+    previewCharacter(4, "Marisol"),+    previewCharacter(5, "Aunt Bel", aliases: ["Belisaria"]),+    previewCharacter(6, "The Cartographer of the Lower Reaches and Her Several Apprentices"),+    previewCharacter(7, "Hale"),+    previewCharacter(8, "Osric"),+    previewCharacter(9, "Sennet"),+    previewCharacter(10, "Dr Aliyah Karro"),+    previewCharacter(11, "The Second Mate"),+    previewCharacter(12, "Pell"),+]++/// A cast short enough for two rows: no expander, and the row is what it was+/// before the limit existed.+#Preview("Cast chips — a cast that fits") {+    ScrollView {+        VStack(alignment: .leading, spacing: 8) {+            ShareCharacterChipsView(+                characters: Array(previewCast.prefix(4)),+                identifierPrefix: "capture.characters")+        }+        .frame(maxWidth: .infinity, alignment: .leading)+        .padding()+        .constellationField()+        .padding()+    }+}++/// Decision 1: twelve names, two rows, and a `+N more` chip ending the second.+#Preview("Cast chips — a long cast, collapsed") {+    ScrollView {+        VStack(alignment: .leading, spacing: 8) {+            ShareCharacterChipsView(+                characters: previewCast, identifierPrefix: "capture.characters")+        }+        .frame(maxWidth: .infinity, alignment: .leading)+        .padding()+        .constellationField()+        .padding()+    }+}++#Preview("Cast chips — a long cast, expanded") {+    ScrollView {+        ShareCharacterChipsView(+            characters: previewCast, identifierPrefix: "reshare.characters", expanded: true)+            .padding()+    }+}++#Preview("Cast chips — free-standing") {+    ScrollView {+        ShareCharacterChipsView(+            characters: Array(previewCast.prefix(4)), identifierPrefix: "reshare.characters")+            .padding()+    }+}++#Preview("Cast chips — open card, multi-line note") {+    ScrollView {+        ShareCharacterChipsView(+            characters: previewCast, identifierPrefix: "capture.characters",+            openFor: previewCast[0].id)+            .padding()+    }+}++/// Q5: a chip with neither aliases nor a note still opens a card.+#Preview("Cast chips — open card, nothing behind it") {+    ScrollView {+        ShareCharacterChipsView(+            characters: previewCast, identifierPrefix: "reshare.characters",+            openFor: previewCast[3].id)+            .padding()+    }+}++/// Q10: the sixth name is wider than the sheet. It truncates inside the row+/// rather than running off the edge, and the card's copy of it wraps. Expanded,+/// because a row wider than two would otherwise not reach it.+#Preview("Cast chips — a name wider than the row") {+    ScrollView {+        ShareCharacterChipsView(+            characters: previewCast, identifierPrefix: "capture.characters",+            openFor: previewCast[5].id, expanded: true)+            .padding()+    }+}++/// At `.accessibility5` a chip takes most of the row, so two rows hold very few+/// names and the expander stands in for nearly the whole cast.+#Preview("Cast chips — accessibility text") {+    ScrollView {+        ShareCharacterChipsView(+            characters: previewCast, identifierPrefix: "capture.characters")+            .padding()+    }+    .environment(\.dynamicTypeSize, .accessibility5)+}++#Preview("Cast chips — accessibility text, open card") {+    ScrollView {+        ShareCharacterChipsView(+            characters: previewCast, identifierPrefix: "reshare.characters",+            openFor: previewCast[0].id)+            .padding()+    }+    .environment(\.dynamicTypeSize, .accessibility5)+}+#endif
Asterism/AsterismShareExtension/ShareCharactersRow.swift Deleted +0 / -29
diff --git a/Asterism/AsterismShareExtension/ShareCharactersRow.swift b/Asterism/AsterismShareExtension/ShareCharactersRow.swiftdeleted file mode 100644index 5137b02e..00000000--- a/Asterism/AsterismShareExtension/ShareCharactersRow.swift+++ /dev/null@@ -1,29 +0,0 @@-import SwiftUI--/// `Characters: Alice (Al, Ally), Bob` — the one cast row both capture sheets-/// draw (T-1916, Q6), in the metadata card's row shape with the glyph on the-/// first line of a text that may wrap to several (Q20).-///-/// The text is `ShareCharacterRow.text(for:)`'s, which is nil for an empty-/// list — a work without characters, a projection that names no existing work-/// and a failed read all draw nothing at all, so the caller decides whether the-/// row exists and this view only lays it out.-struct ShareCharactersRow: View {-    let text: String-    let accessibilityIdentifier: String--    var body: some View {-        HStack(alignment: .firstTextBaseline, spacing: 6) {-            Image(systemName: "person.2")-                .font(.caption)-                .foregroundStyle(.secondary)-                .accessibilityHidden(true)-            Text(text)-                .font(.subheadline)-                .fixedSize(horizontal: false, vertical: true)-        }-        .accessibilityElement(children: .combine)-        .accessibilityLabel(text)-        .accessibilityIdentifier(accessibilityIdentifier)-    }-}
CHANGELOG.md Modified +27 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d9ba35a8..9d9407d8 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -936,6 +936,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Changed +- **Share sheet character chips (T-2317).** Both capture sheets drew a+  work's cast as one text row, `Characters: Alice (Al, Ally), Bob`, so a+  reader writing a chapter note could not see what they had written down+  about a character without leaving the sheet. The cast is now one chip+  per character carrying the main name only, in the order the read+  already produces. Tapping a chip opens one inline card beneath the row+  with that character's aliases and whole note; tapping it again closes+  it, tapping another replaces it, and a character with neither says+  `No aliases or notes yet.` A cast that needs more than two chip rows+  is capped at two, the last chip of the second row reading `+N more`;+  tapping it draws everyone with a trailing `Show fewer`, the sheet+  always opens collapsed, hidden chips are not rendered so neither a tap+  nor VoiceOver reaches them, and collapsing closes a card whose chip it+  hides (Decision 1, which supersedes Q2's acceptance of the height+  after the first run on a phone). How many chips two rows hold is+  `FlowLayout.visibleCount`, a tested function of measured sizes. Facts+  stay out of the extension. The share+  read carries each character's record id and trimmed note with no extra+  fetch, and the pending-capture drain still reads no characters. The+  open and close rules and every VoiceOver string are tested values in+  `AsterismCore` (`ShareCharacterChips`), which replaces+  `ShareCharacterRow`; VoiceOver focus moves to the card when it opens.+  `FlowLayout` moved from the app target into ConstellationKit so the+  extension can use it, and an item wider than its row is now proposed+  the row width instead of overflowing; content that fits is laid out as+  before.+ - **Xcode 27 recommended project settings.** Accepted Xcode 27's   upgrade suggestions: `DEAD_CODE_STRIPPING = YES` on every target and   at project level, `STRING_CATALOG_GENERATE_SYMBOLS = YES` at project
Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swift Modified +2 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swift b/Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swiftindex 8c39d25f..714be308 100644--- a/Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swift@@ -273,7 +273,8 @@ enum RecordRanking {     }      /// The same order for a caller that wants only the groups — the share-    /// sheet, which shows names and aliases (Q43).+    /// sheet, whose cast chips show names, with aliases and notes in the detail+    /// card (Q43, T-2317).     ///     /// Each group's facts are decoded, scored and dropped before the next     /// group's are read, so the extension never holds the whole cast's facts at
Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift Modified +104 / -26
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift b/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swiftindex 3f1f3724..fc443d1a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift@@ -8,8 +8,8 @@ import SwiftData // // Derived here rather than in the extension for the reason sharesheet-polish Q6 // records for every other row of that card: the extension lays text out, it does-// not decide what the text says. It also keeps the order one answer — the share-// row and the work page rank the same groups through `RecordRanking`,+// not decide what the text says. It also keeps the order one answer — the cast+// chips and the work page rank the same groups through `RecordRanking`, // against the whole work on both (Decision 1 of `character-ranking`), and the // last note is selected the way the work page orders its chapters. @@ -19,40 +19,112 @@ import SwiftData private let shareWorkContextLogger = Logger(     subsystem: "AsterismCore", category: "ShareWorkContext") -/// One character of a work on a capture sheet: what to call them, and what else-/// they are called.+/// One character of a work on a capture sheet: what to call them, what else+/// they are called, and what the reader wrote down about them. ///-/// Name and aliases only (Q2). A fact cites an entry anywhere in the work,-/// including chapters ahead of the one being shared, and the sheet has no-/// spoiler boundary to keep them behind.-public struct ShareCharacter: Sendable, Equatable {+/// No facts (Q2). A fact cites an entry anywhere in the work, including chapters+/// ahead of the one being shared, and the sheet has no spoiler boundary to keep+/// them behind. The note is the reader's own text and comes along+/// (`share-sheet-character-chips` Q1).+public struct ShareCharacter: Sendable, Equatable, Identifiable {+    /// The record group's UUID — what the sheet tracks its open chip by, because+    /// two characters may share a name and an index moves under a refresh (Q6).+    public let id: UUID     public let name: String     /// In the order `RecordAuthoredContent` holds them, which is sorted at     /// construction — the order the work page shows too.     public let aliases: [String]+    /// The note the work page shows for this character, trimmed of surrounding+    /// whitespace by the read; `""` where there is none.+    public let note: String -    public init(name: String, aliases: [String] = []) {+    public init(id: UUID, name: String, aliases: [String] = [], note: String = "") {+        self.id = id         self.name = name         self.aliases = aliases+        self.note = note     } } -/// The single wrapping text row both capture sheets draw for a cast (Q6).-public enum ShareCharacterRow {+/// The chip row's open/close rules and every string it and its detail card+/// speak, produced here so the extension only lays them out (sharesheet-polish+/// Q6, Q9): the extension has no XCUITest coverage by decision, so a rule that+/// lived in a view would be a rule nothing checks.+public enum ShareCharacterChips { -    /// `Characters: Alice (Al, Ally), Bob`, or nil where there are none.-    ///-    /// Nil rather than an empty string: a work without characters draws no-    /// element at all, so the sheet's state for it is the state for a work whose-    /// read failed.-    public static func text(for characters: [ShareCharacter]) -> String? {-        guard !characters.isEmpty else { return nil }-        let listed = characters.map { character in-            character.aliases.isEmpty-                ? character.name-                : "\(character.name) (\(character.aliases.joined(separator: ", ")))"+    /// Which chip is open after a tap: the tapped one, unless it was already+    /// open, in which case none is (Q4). At most one card is ever open.+    public static func toggled(open: UUID?, tapped: UUID) -> UUID? {+        open == tapped ? nil : tapped+    }++    /// The character the open id names, or nil — for no open id, and for one a+    /// refresh has dropped from the cast, which leaves no card open.+    public static func openCharacter(+        _ id: UUID?, in characters: [ShareCharacter]+    ) -> ShareCharacter? {+        guard let id else { return nil }+        return characters.first { $0.id == id }+    }++    /// The VoiceOver group label over the chips. Bare names with no context is+    /// what dropping the old row's `Characters:` prefix would otherwise leave+    /// (Q11).+    public static let groupLabel = "Characters"++    /// How many rows of chips a collapsed cast is drawn to (Decision 1). A cast+    /// that fits inside it is drawn whole, with no expander.+    public static let visibleRowLimit = 2++    /// The expander chip's own text, where `hidden` characters are not drawn.+    /// The count is the point of it: `Show all` says nothing about whether one+    /// name is behind it or thirty.+    public static func expanderText(hidden: Int) -> String {+        "+\(hidden) more"+    }++    /// What VoiceOver reads for the expander — a button, so it says what the tap+    /// does rather than reading `+3 more` out.+    public static func expanderLabel(hidden: Int) -> String {+        hidden == 1 ? "Show 1 more character" : "Show \(hidden) more characters"+    }++    /// The trailing chip of an expanded cast, and what VoiceOver reads for it.+    public static let collapseText = "Show fewer"+    public static let collapseLabel = "Show fewer characters"++    /// The open card after a collapse: it stays open only if its own chip is+    /// still drawn. A card under no chip is a card the reader cannot close by+    /// the tap that opened it.+    public static func openAfterCollapse(open: UUID?, visible: [ShareCharacter]) -> UUID? {+        openCharacter(open, in: visible)?.id+    }++    /// What a card with neither aliases nor a note reads under the name. Every+    /// chip opens a card, because a chip that ignores a tap reads as broken+    /// (Q5).+    public static let emptyDetailText = "No aliases or notes yet."++    /// A chip's VoiceOver value — whether its card is showing.+    public static func chipValue(isOpen: Bool) -> String {+        isOpen ? "Details shown" : "Details hidden"+    }++    /// The whole card as one spoken element: `Alice. Also known as Al, Ally.+    /// Keeps the lamp.` — an absent half omitted, and `emptyDetailText` under+    /// the name where both are absent.+    public static func detailLabel(for character: ShareCharacter) -> String {+        var parts = ["\(character.name)."]+        if !character.aliases.isEmpty {+            parts.append("Also known as \(character.aliases.joined(separator: ", ")).")+        }+        if !character.note.isEmpty {+            parts.append(character.note)+        }+        if parts.count == 1 {+            parts.append(emptyDetailText)         }-        return "Characters: " + listed.joined(separator: ", ")+        return parts.joined(separator: " ")     } } @@ -220,8 +292,8 @@ extension LibraryRepository {         // index, the scoring — so a failure in the notes half below costs the         // notes and not the cast (`share-sheet-characters` Q15).         let storyPositions = StoryPositionIndex(entries: storyPositionInputs(buckets: rowsByID))-        // `rankGroups`, not `rank`: this sheet draws names and aliases, so a-        // character's facts are decoded, scored and dropped before the next+        // `rankGroups`, not `rank`: this sheet draws names, aliases and notes,+        // so a character's facts are decoded, scored and dropped before the next         // character's are read rather than the whole cast's being held at once         // (Q43). The order is `rank`'s own.         let characters = RecordRanking.rankGroups(@@ -229,7 +301,13 @@ extension LibraryRepository {         )         .map { group in             let content = group.presentedContent-            return ShareCharacter(name: content.name, aliases: content.aliases)+            // The group's id, not the row's: two rows of a torn character are+            // one chip, and the sheet tracks its open chip by this (Q6). The+            // note is trimmed here, as the work note below is — the views+            // trim nothing.+            return ShareCharacter(+                id: group.id, name: content.name, aliases: content.aliases,+                note: content.note.trimmingCharacters(in: .whitespacesAndNewlines))         }          do {
Packages/AsterismCore/Sources/ConstellationKit/FlowLayout.swift Added +153 / -0
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/FlowLayout.swift b/Packages/AsterismCore/Sources/ConstellationKit/FlowLayout.swiftnew file mode 100644index 00000000..e2d9641a--- /dev/null+++ b/Packages/AsterismCore/Sources/ConstellationKit/FlowLayout.swift@@ -0,0 +1,153 @@+import SwiftUI++/// Simple wrapping layout for chips.+///+/// Here rather than in the app target because the share extension draws a cast+/// of chips too and already links this package; a second copy would drift (Q3).+/// The app's uses of it — fifteen, across eight views — are unchanged by the+/// move.+public struct FlowLayout: Layout {+    public var spacing: CGFloat++    public init(spacing: CGFloat = 8) {+        self.spacing = spacing+    }++    public func sizeThatFits(+        proposal: ProposedViewSize, subviews: Subviews, cache: inout ()+    ) -> CGSize {+        let rowWidth = proposal.width ?? .infinity+        return Self.arrangement(+            of: measured(subviews, rowWidth: rowWidth).sizes, rowWidth: rowWidth,+            spacing: spacing+        ).size+    }++    public func placeSubviews(+        in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()+    ) {+        let rowWidth = bounds.width+        let measurement = measured(subviews, rowWidth: rowWidth)+        let result = Self.arrangement(+            of: measurement.sizes, rowWidth: rowWidth, spacing: spacing)+        for (index, position) in result.positions.enumerated() {+            subviews[index].place(+                at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),+                // Q10: only a subview that does not fit hears about the row+                // width. Everything else keeps the `.unspecified` proposal the+                // app's call sites rely on — a `Label` under `.automatic`+                // resolves to its icon alone when it is given a width+                // (`WorkDetailView`'s meta line).+                proposal: measurement.isClamped[index]+                    ? ProposedViewSize(width: rowWidth, height: nil)+                    : .unspecified)+        }+    }++    /// Each subview's size, and whether it had to be re-measured against the+    /// row because its ideal width does not fit on one.+    private func measured(+        _ subviews: Subviews, rowWidth: CGFloat+    ) -> (sizes: [CGSize], isClamped: [Bool]) {+        var sizes: [CGSize] = []+        var isClamped: [Bool] = []+        for subview in subviews {+            let ideal = subview.sizeThatFits(.unspecified)+            if ideal.width > rowWidth {+                sizes.append(+                    subview.sizeThatFits(ProposedViewSize(width: rowWidth, height: nil)))+                isClamped.append(true)+            } else {+                sizes.append(ideal)+                isClamped.append(false)+            }+        }+        return (sizes, isClamped)+    }++    /// Where the items go and how much room they need — the whole of the+    /// layout's arithmetic, as a pure function of the sizes so it can be+    /// asserted without a render.+    public struct Arrangement: Equatable, Sendable {+        public var size: CGSize+        public var positions: [CGPoint]++        /// How many rows the items landed on. Positions are produced in order+        /// and a row only ever moves down, so a change of `y` is a new row.+        public var rowCount: Int {+            var rows = 0+            var lastY: CGFloat?+            for position in positions where position.y != lastY {+                rows += 1+                lastY = position.y+            }+            return rows+        }+    }++    /// Items laid left to right, wrapping when the next one would cross+    /// `rowWidth`, each row as tall as its tallest item.+    ///+    /// An item wider than the row is counted at the row width (Q10): a subview+    /// that truncates or wraps still occupies the row it is on, and the item+    /// after it starts below rather than off the edge.+    public static func arrangement(+        of sizes: [CGSize], rowWidth: CGFloat, spacing: CGFloat+    ) -> Arrangement {+        var positions: [CGPoint] = []+        var x: CGFloat = 0+        var y: CGFloat = 0+        var rowHeight: CGFloat = 0+        var totalWidth: CGFloat = 0++        for size in sizes {+            let width = min(size.width, rowWidth)+            if x + width > rowWidth, x > 0 {+                x = 0+                y += rowHeight + spacing+                rowHeight = 0+            }+            positions.append(CGPoint(x: x, y: y))+            rowHeight = max(rowHeight, size.height)+            x += width + spacing+            totalWidth = max(totalWidth, x - spacing)+        }+        return Arrangement(+            size: CGSize(width: totalWidth, height: y + rowHeight), positions: positions)+    }++    /// How many leading items fit in `rowLimit` rows once room is kept on the+    /// last of them for a trailing expander.+    ///+    /// The answer is `sizes.count` when everything already fits: a collection+    /// inside the limit needs no expander and gives up no item to one. Otherwise+    /// it is the largest `k` for which the first `k` items *plus* the expander+    /// still lay out inside the limit — so the expander never wraps onto a row+    /// the caller is not going to draw, which is the whole point of reserving it+    /// (`share-sheet-character-chips` Decision 1).+    ///+    /// Answered by laying candidates out rather than by re-deriving the wrap+    /// arithmetic: the truncated row has to agree with the row the caller then+    /// draws, and there is only one way to be sure of that.+    public static func visibleCount(+        of sizes: [CGSize], expander: CGSize, rowWidth: CGFloat, spacing: CGFloat,+        rowLimit: Int+    ) -> Int {+        guard rowLimit > 0, !sizes.isEmpty else { return 0 }+        if arrangement(of: sizes, rowWidth: rowWidth, spacing: spacing).rowCount <= rowLimit {+            return sizes.count+        }+        // At least one item is already out, so the expander has to be drawn and+        // has to be paid for. Another leading item never takes a row away, so+        // walk up and stop at the first count that no longer fits: the cost+        // follows what two rows hold, not the size of the collection.+        var visible = 0+        while visible + 1 < sizes.count {+            let candidate = Array(sizes.prefix(visible + 1)) + [expander]+            let rows = arrangement(of: candidate, rowWidth: rowWidth, spacing: spacing).rowCount+            if rows > rowLimit { break }+            visible += 1+        }+        return visible+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift Modified +45 / -22
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swiftindex ed377fa1..6520dbc0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift@@ -408,6 +408,12 @@ struct CaptureStateTests {      // MARK: - Work context: characters row (T-1916) and catch-up section (T-1917) +    /// Fixed ids for the cast these cases hand the view model. `ShareCharacter`+    /// has no defaulted id on purpose (Q6): a fresh `UUID()` per construction+    /// would compile and silently break every equality below.+    private static let aliceID = UUID(uuidString: "C0000000-0000-4000-8000-00000000000A")!+    private static let bobID = UUID(uuidString: "C0000000-0000-4000-8000-00000000000B")!+     @Test("A reuse projection reads the projected work's context once")     @MainActor func reuseProjectionReadsWorkContext() async throws {         let fixture = CaptureStateFixture.taughtSite()@@ -416,8 +422,8 @@ struct CaptureStateTests {             lastSharedAt: Date(timeIntervalSince1970: 1_800_000_000))         fixture.coordinator.shareWorkContextResult = .success(ShareWorkContext(             characters: [-                ShareCharacter(name: "Alice", aliases: ["Al"]),-                ShareCharacter(name: "Bob")+                ShareCharacter(id: Self.aliceID, name: "Alice", aliases: ["Al"]),+                ShareCharacter(id: Self.bobID, name: "Bob")             ],             workNote: "Reading with Ada.",             lastNote: lastNote))@@ -450,12 +456,14 @@ struct CaptureStateTests {             composedAssignment: .claim(workID: claimedID)         )))         fixture.coordinator.shareWorkContextResult = .success(-            ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+            ShareWorkContext(characters: [ShareCharacter(id: Self.aliceID, name: "Alice")]))         await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)         await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)          #expect(fixture.coordinator.shareWorkContextWorkIDs == [claimedID])-        #expect(fixture.viewModel.workContext.characters == [ShareCharacter(name: "Alice")])+        #expect(+            fixture.viewModel.workContext.characters+                == [ShareCharacter(id: Self.aliceID, name: "Alice")])     }      @Test("A projection that names no existing work reads nothing",@@ -480,7 +488,7 @@ struct CaptureStateTests {     @MainActor func unchangedWorkDoesNotRefetch() async throws {         let fixture = CaptureStateFixture.taughtSite()         fixture.coordinator.shareWorkContextResult = .success(-            ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+            ShareWorkContext(characters: [ShareCharacter(id: Self.aliceID, name: "Alice")]))         await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)         await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)         #expect(fixture.coordinator.shareWorkContextCalls.count == 1)@@ -491,7 +499,9 @@ struct CaptureStateTests {         await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)          #expect(fixture.coordinator.shareWorkContextCalls.count == 1)-        #expect(fixture.viewModel.workContext.characters == [ShareCharacter(name: "Alice")])+        #expect(+            fixture.viewModel.workContext.characters+                == [ShareCharacter(id: Self.aliceID, name: "Alice")])     }      /// The guard keys on the *derived* placement, not on the raw chapter pair.@@ -504,7 +514,7 @@ struct CaptureStateTests {     @MainActor func aTitleEditWithinOnePlacementDoesNotRefetch() async throws {         let fixture = CaptureStateFixture.taughtSite()         let workID = fixture.coordinator.projectedWorkID-        let cast = [ShareCharacter(name: "Alice")]+        let cast = [ShareCharacter(id: Self.aliceID, name: "Alice")]         fixture.coordinator.shareWorkContextResult = .success(ShareWorkContext(characters: cast))         fixture.coordinator.projectCaptureResult = .success(             makeContract(outcome: makeReuseOutcome(chapter: "Chapter 6", workID: workID)))@@ -536,7 +546,7 @@ struct CaptureStateTests {         let fixture = CaptureStateFixture.taughtSite()         let workID = fixture.coordinator.projectedWorkID         fixture.coordinator.shareWorkContextResult = .success(-            ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+            ShareWorkContext(characters: [ShareCharacter(id: Self.aliceID, name: "Alice")]))         fixture.coordinator.projectCaptureResult = .success(             makeContract(outcome: makeReuseOutcome(chapter: "Chapter 5", workID: workID)))         await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)@@ -555,7 +565,9 @@ struct CaptureStateTests {             RecordedContextRead(workID: workID, chapterSequence: nil, chapterTitle: "Chapter 5"),             RecordedContextRead(workID: workID, chapterSequence: nil, chapterTitle: "Chapter 55"),         ])-        #expect(fixture.viewModel.displayedWorkContext.characters == [ShareCharacter(name: "Alice")])+        #expect(+            fixture.viewModel.displayedWorkContext.characters+                == [ShareCharacter(id: Self.aliceID, name: "Alice")])     }      /// Decision 4 widened T-1916's guard from the work to the work *and* the@@ -567,7 +579,7 @@ struct CaptureStateTests {         let fixture = CaptureStateFixture.taughtSite()         let workID = fixture.coordinator.projectedWorkID         fixture.coordinator.shareWorkContextResult = .success(-            ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+            ShareWorkContext(characters: [ShareCharacter(id: Self.aliceID, name: "Alice")]))         await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)         await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)         #expect(fixture.coordinator.shareWorkContextCalls.count == 1)@@ -590,7 +602,9 @@ struct CaptureStateTests {             RecordedContextRead(workID: workID, chapterSequence: nil, chapterTitle: "5"),             RecordedContextRead(workID: workID, chapterSequence: "1043128", chapterTitle: "6"),         ])-        #expect(fixture.viewModel.displayedWorkContext.characters == [ShareCharacter(name: "Alice")])+        #expect(+            fixture.viewModel.displayedWorkContext.characters+                == [ShareCharacter(id: Self.aliceID, name: "Alice")])     }      @Test("A read superseded before it returns is never published")@@ -599,7 +613,7 @@ struct CaptureStateTests {         let firstWorkID = fixture.coordinator.projectedWorkID         let secondWorkID = UUID()         fixture.coordinator.shareWorkContextResult = .success(-            ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+            ShareWorkContext(characters: [ShareCharacter(id: Self.aliceID, name: "Alice")]))         await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)          // Reproject onto another work while the read is suspended: the view model@@ -631,7 +645,7 @@ struct CaptureStateTests {         let viewModel = fixture.viewModel         let workID = coordinator.projectedWorkID         coordinator.shareWorkContextResult = .success(-            ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+            ShareWorkContext(characters: [ShareCharacter(id: Self.aliceID, name: "Alice")]))         await viewModel.load(payload: fixture.defaultPayload, coordinator: coordinator)          // Read 1 for the work, held suspended inside the coordinator.@@ -673,14 +687,18 @@ struct CaptureStateTests {         firstResumes.open()         _ = await firstRead.value         #expect(viewModel.inFlightKey?.workID == workID)-        #expect(viewModel.displayedWorkContext.characters == [ShareCharacter(name: "Alice")])+        #expect(+            viewModel.displayedWorkContext.characters+                == [ShareCharacter(id: Self.aliceID, name: "Alice")])          // Read 2 owns the marker, so read 2 releases it.         secondResumes.open()         _ = await secondRead.value         #expect(viewModel.inFlightKey == nil)         #expect(coordinator.shareWorkContextWorkIDs == [workID, workID])-        #expect(viewModel.displayedWorkContext.characters == [ShareCharacter(name: "Alice")])+        #expect(+            viewModel.displayedWorkContext.characters+                == [ShareCharacter(id: Self.aliceID, name: "Alice")])     }      @Test("A failed read leaves the state an empty work produces")@@ -700,14 +718,15 @@ struct CaptureStateTests {         #expect(failing.viewModel.workContext == empty.viewModel.workContext)         #expect(failing.viewModel.contextKey == empty.viewModel.contextKey)         #expect(failing.viewModel.inFlightKey == empty.viewModel.inFlightKey)-        #expect(ShareCharacterRow.text(for: failing.viewModel.workContext.characters) == nil)+        // An empty cast is what draws no chips at all (T-2317).+        #expect(failing.viewModel.workContext.characters.isEmpty)     }      @Test("Losing the projected work clears the context")     @MainActor func losingTheWorkClearsTheContext() async throws {         let fixture = CaptureStateFixture.taughtSite()         fixture.coordinator.shareWorkContextResult = .success(-            ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+            ShareWorkContext(characters: [ShareCharacter(id: Self.aliceID, name: "Alice")]))         await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)         await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)         #expect(fixture.viewModel.workContext.characters.isEmpty == false)@@ -730,10 +749,12 @@ struct CaptureStateTests {     @MainActor func displayedContextDropsOnAWorkChange() async throws {         let fixture = CaptureStateFixture.taughtSite()         fixture.coordinator.shareWorkContextResult = .success(-            ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+            ShareWorkContext(characters: [ShareCharacter(id: Self.aliceID, name: "Alice")]))         await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)         await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)-        #expect(fixture.viewModel.displayedWorkContext.characters == [ShareCharacter(name: "Alice")])+        #expect(+            fixture.viewModel.displayedWorkContext.characters+                == [ShareCharacter(id: Self.aliceID, name: "Alice")])          // The projection moves to another existing work. Nothing has been read         // for it yet, so the sheet must not show the first work's cast under the@@ -743,14 +764,16 @@ struct CaptureStateTests {          #expect(fixture.viewModel.displayedWorkContext.characters.isEmpty)         // The loaded list is untouched — the rule is about what may be drawn.-        #expect(fixture.viewModel.workContext.characters == [ShareCharacter(name: "Alice")])+        #expect(+            fixture.viewModel.workContext.characters+                == [ShareCharacter(id: Self.aliceID, name: "Alice")])     }      @Test("A projection that names no existing work draws nothing at once")     @MainActor func displayedContextDropsOnACreate() async throws {         let fixture = CaptureStateFixture.taughtSite()         fixture.coordinator.shareWorkContextResult = .success(-            ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+            ShareWorkContext(characters: [ShareCharacter(id: Self.aliceID, name: "Alice")]))         await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)         await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator) @@ -771,7 +794,7 @@ struct CaptureStateTests {     @MainActor func loadReportsWhetherItPublished() async throws {         let fixture = CaptureStateFixture.taughtSite()         fixture.coordinator.shareWorkContextResult = .success(-            ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+            ShareWorkContext(characters: [ShareCharacter(id: Self.aliceID, name: "Alice")]))         await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)          let published = await fixture.viewModel.loadWorkContextIfNeeded(
Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift Modified +9 / -5
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swiftindex 11ecf45b..ba2b7ec3 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift@@ -255,9 +255,14 @@ struct ReShareExtensionUITests {      // MARK: - The work's cast and notes on the edit sheet (T-1916, T-1917) +    /// Fixed ids: `ShareCharacter` has no defaulted id (Q6), so the cast a+    /// fixture hands the view model is the cast the assertions compare against.+    private static let aliceID = UUID(uuidString: "C0000000-0000-4000-8000-00000000020A")!+    private static let bobID = UUID(uuidString: "C0000000-0000-4000-8000-00000000020B")!+     private static let cast = [-        ShareCharacter(name: "Alice", aliases: ["Al", "Ally"]),-        ShareCharacter(name: "Bob"),+        ShareCharacter(id: aliceID, name: "Alice", aliases: ["Al", "Ally"]),+        ShareCharacter(id: bobID, name: "Bob"),     ]      private static let lastNote = ShareLastNote(@@ -279,8 +284,6 @@ struct ReShareExtensionUITests {             return         }         #expect(state.workContext.characters == Self.cast)-        #expect(ShareCharacterRow.text(for: state.workContext.characters)-            == "Characters: Alice (Al, Ally), Bob")         #expect(state.workContext.workNote == "Reading with Ada.")         #expect(state.workContext.lastNote == Self.lastNote)         #expect(state.workContext.hasCatchUp)@@ -299,7 +302,8 @@ struct ReShareExtensionUITests {         }         #expect(state.workContext == .empty)         #expect(!state.workContext.hasCatchUp)-        #expect(ShareCharacterRow.text(for: state.workContext.characters) == nil)+        // An empty cast is what draws no chips at all (T-2317).+        #expect(state.workContext.characters.isEmpty)     }      /// Q11: the commit path reads no work context, so its refreshed basis
Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift Modified +163 / -51
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swiftindex 2f8ba1aa..7530a042 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift@@ -7,48 +7,106 @@ import Testing // T-1916/T-1917: what a capture sheet shows for a work it already knows — the // cast, the work's own notes and the last chapter note. //-// Three claims. The row text and the heading are pure functions of the-// projection, so the extension decides nothing about either (Q6); the-// projection answers with the work page's characters, notes and order, for the-// same store; and the re-share lookup carries the whole context along, only for-// the caller that displays it (Q10).--@Suite("Share character row text")-struct ShareCharacterRowTests {--    @Test("No characters means no row at all")-    func emptyListHasNoText() {-        #expect(ShareCharacterRow.text(for: []) == nil)+// Three claims. The chip row's rules and the last note's heading are pure+// functions of the projection, so the extension decides nothing about either+// (Q6); the projection answers with the work page's characters, notes and+// order, for the same store; and the re-share lookup carries the whole context+// along, only for the caller that displays it (Q10).++// T-2317: the chip row decides nothing of its own — which chip is open after a+// tap, which character that id names, and every string VoiceOver reads are+// values here, because the extension has no XCUITest coverage by decision (Q9).++@Suite("Share character chips")+struct ShareCharacterChipsTests {++    private static let alice = UUID(uuidString: "1F000000-0000-4000-8000-00000000020A")!+    private static let bob = UUID(uuidString: "1F000000-0000-4000-8000-00000000020B")!+    private static let gone = UUID(uuidString: "1F000000-0000-4000-8000-00000000020F")!++    private static let cast = [+        ShareCharacter(+            id: alice, name: "Alice", aliases: ["Al", "Ally"], note: "Keeps the lamp."),+        ShareCharacter(id: bob, name: "Bob"),+    ]++    @Test("A tap opens a chip, the same tap closes it, another replaces it")+    func toggleTruthTable() {+        #expect(ShareCharacterChips.toggled(open: nil, tapped: Self.alice) == Self.alice)+        #expect(ShareCharacterChips.toggled(open: Self.alice, tapped: Self.alice) == nil)+        #expect(ShareCharacterChips.toggled(open: Self.alice, tapped: Self.bob) == Self.bob)+        #expect(ShareCharacterChips.toggled(open: Self.bob, tapped: Self.alice) == Self.alice)     } -    @Test("Names are comma-separated, aliases parenthesised in the order given")-    func namesAndAliasesRead() {-        let text = ShareCharacterRow.text(for: [-            ShareCharacter(name: "Alice", aliases: ["Al", "Ally"]),-            ShareCharacter(name: "Bob"),-        ])-        #expect(text == "Characters: Alice (Al, Ally), Bob")+    @Test("The open id names a character, or nothing where it is absent or nil")+    func openCharacterResolves() {+        #expect(ShareCharacterChips.openCharacter(Self.alice, in: Self.cast)?.name == "Alice")+        // A refresh that drops the open character leaves no card open.+        #expect(ShareCharacterChips.openCharacter(Self.gone, in: Self.cast) == nil)+        #expect(ShareCharacterChips.openCharacter(nil, in: Self.cast) == nil)+        #expect(ShareCharacterChips.openCharacter(Self.alice, in: []) == nil)     } -    @Test("An alias list is drawn as stored — sorted, not re-ordered here")-    func aliasOrderIsTheStoredOrder() {-        // `RecordAuthoredContent` sorts aliases at construction, so the row-        // has nothing left to decide; it must not impose a second order.-        let content = RecordAuthoredContent(name: "Hanna", aliases: ["Zephyr", "Ann"])-        let text = ShareCharacterRow.text(for: [-            ShareCharacter(name: content.name, aliases: content.aliases)-        ])-        #expect(content.aliases == ["Ann", "Zephyr"])-        #expect(text == "Characters: Hanna (Ann, Zephyr)")+    /// Decision 1: a cast past two rows is drawn to two, and the expander says+    /// how many names it is standing in for.+    @Test("The expander counts the names it stands in for, and pluralises its label")+    func expanderStrings() {+        #expect(ShareCharacterChips.visibleRowLimit == 2)+        #expect(ShareCharacterChips.expanderText(hidden: 1) == "+1 more")+        #expect(ShareCharacterChips.expanderText(hidden: 12) == "+12 more")+        #expect(ShareCharacterChips.expanderLabel(hidden: 1) == "Show 1 more character")+        #expect(ShareCharacterChips.expanderLabel(hidden: 12) == "Show 12 more characters")+        #expect(ShareCharacterChips.collapseText == "Show fewer")+        #expect(ShareCharacterChips.collapseLabel == "Show fewer characters")     } -    @Test("A name containing a comma is joined verbatim (accepted, Q6)")-    func commaInANameIsNotEscaped() {-        let text = ShareCharacterRow.text(for: [-            ShareCharacter(name: "Vance, the Elder"),-            ShareCharacter(name: "Bo"),-        ])-        #expect(text == "Characters: Vance, the Elder, Bo")+    @Test("Collapsing closes a card whose chip is no longer drawn")+    func collapseClosesAHiddenCard() {+        // Alice is still drawn, so her card survives the collapse.+        #expect(+            ShareCharacterChips.openAfterCollapse(open: Self.alice, visible: [Self.cast[0]])+                == Self.alice)+        // Bob's chip went with the collapse, so his card goes with it.+        #expect(+            ShareCharacterChips.openAfterCollapse(open: Self.bob, visible: [Self.cast[0]]) == nil)+        #expect(ShareCharacterChips.openAfterCollapse(open: nil, visible: Self.cast) == nil)+        #expect(ShareCharacterChips.openAfterCollapse(open: Self.alice, visible: []) == nil)+    }++    @Test("A chip says whether its card is showing")+    func chipValueSpeaksTheState() {+        #expect(ShareCharacterChips.chipValue(isOpen: true) == "Details shown")+        #expect(ShareCharacterChips.chipValue(isOpen: false) == "Details hidden")+        #expect(ShareCharacterChips.groupLabel == "Characters")+    }++    @Test("The card reads as the name, then the aliases, then the note")+    func detailLabelWithBothHalves() {+        #expect(+            ShareCharacterChips.detailLabel(for: Self.cast[0])+                == "Alice. Also known as Al, Ally. Keeps the lamp.")+    }++    @Test("An absent half is left out, and the alias order is the read's")+    func detailLabelOmitsAnAbsentHalf() {+        #expect(+            ShareCharacterChips.detailLabel(+                for: ShareCharacter(id: Self.alice, name: "Hanna", aliases: ["Ann", "Zephyr"]))+                == "Hanna. Also known as Ann, Zephyr.")+        #expect(+            ShareCharacterChips.detailLabel(+                for: ShareCharacter(id: Self.bob, name: "Bob", note: "Carries the rope."))+                == "Bob. Carries the rope.")+    }++    /// Q5: a chip with nothing behind it still opens a card, so the card has to+    /// say so rather than come up blank.+    @Test("A character with neither aliases nor a note still reads as a card")+    func detailLabelForNeither() {+        #expect(+            ShareCharacterChips.detailLabel(for: Self.cast[1])+                == "Bob. \(ShareCharacterChips.emptyDetailText)")+        #expect(ShareCharacterChips.emptyDetailText == "No aliases or notes yet.")     } } @@ -250,16 +308,15 @@ struct ShareWorkContextReadTests {         // Name order would read alice, Bob, Cora, Zed, Zed — so nothing here         // passes on the sort the ranking replaced.         #expect(characters == [-            ShareCharacter(name: "Zed", aliases: ["Zeta"]),-            ShareCharacter(name: "Zed", aliases: ["Younger"]),-            ShareCharacter(name: "Bob"),-            ShareCharacter(name: "alice", aliases: ["Al", "Ally"]),-            ShareCharacter(name: "Cora"),+            ShareCharacter(id: Self.zedFirst, name: "Zed", aliases: ["Zeta"]),+            ShareCharacter(id: Self.zedSecond, name: "Zed", aliases: ["Younger"]),+            ShareCharacter(id: Self.bob, name: "Bob"),+            ShareCharacter(id: Self.alice, name: "alice", aliases: ["Al", "Ally"]),+            ShareCharacter(id: Self.cora, name: "Cora"),         ])-        #expect(-            ShareCharacterRow.text(for: characters)-                == "Characters: Zed (Zeta), Zed (Younger), Bob, alice (Al, Ally), Cora")-+        // Q6: the two Zeds are one name and two chips, told apart by the id the+        // read carries, not by where they sit in the list.+        #expect(Set(characters.map(\.id)).count == characters.count)         // The claim that matters: one order, shared with the page (Q9,         // `character-ranking` Req 3.1).         let detail = try await fixture.repository.workDetail(id: Self.workID)@@ -362,8 +419,63 @@ struct ShareWorkContextReadTests {         let presented = try #require(detail.characters.first)         #expect(presented.isTorn)         // One entry for one character, whatever its rows disagree about, and-        // the same text the page shows for it.-        #expect(characters == [ShareCharacter(name: presented.name, aliases: presented.aliases)])+        // the same text the page shows for it — under the group's id, which is+        // the id both rows share (Q6).+        #expect(characters == [+            ShareCharacter(+                id: Self.alice, name: presented.name, aliases: presented.aliases,+                note: presented.note)+        ])+        withExtendedLifetime(fixture) {}+    }++    /// Q6, Q1: the two fields the chips added. The id is the record group's, so+    /// a chip survives a refresh, and the note arrives trimmed so the view+    /// trims nothing.+    @Test("Each character carries its group id and its note, trimmed")+    func charactersCarryTheirIDAndTrimmedNote() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(+                id: Self.alice, name: "Alice", nameKey: "alice",+                note: "  Keeps the lamp.\n\n  ", workID: Self.workID),+            // Nothing written down about him: an empty note, not a blank one.+            M5SeedCharacter(id: Self.bob, name: "Bob", nameKey: "bob", workID: Self.workID),+        ])++        let characters = try await context(fixture).characters+        #expect(characters.count == 2)+        #expect(Set(characters.map(\.id)) == [Self.alice, Self.bob])+        #expect(characters.first { $0.id == Self.alice }?.note == "Keeps the lamp.")+        #expect(characters.first { $0.id == Self.bob }?.note == "")++        // The same read twice is the same value, which is what+        // `CaptureViewModel`'s change check compares (Q6).+        #expect(try await context(fixture).characters == characters)+        withExtendedLifetime(fixture) {}+    }++    /// The seam between the read's trim and the card's empty rule: a note of+    /// nothing but whitespace is no note, and the trim takes the ends only.+    @Test("A blank note reads as no note, and a note keeps the line breaks inside it")+    func blankAndMultiLineNotes() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(+                id: Self.alice, name: "Alice", nameKey: "alice", note: " \n\t ",+                workID: Self.workID),+            M5SeedCharacter(+                id: Self.bob, name: "Bob", nameKey: "bob",+                note: "\nOwes the guild.\n\nStill has the horse.\n", workID: Self.workID),+        ])++        let characters = try await context(fixture).characters+        let alice = try #require(characters.first { $0.id == Self.alice })+        let bob = try #require(characters.first { $0.id == Self.bob })+        #expect(alice.note == "")+        #expect(+            ShareCharacterChips.detailLabel(for: alice)+                == "Alice. \(ShareCharacterChips.emptyDetailText)")+        #expect(bob.note == "Owes the guild.\n\nStill has the horse.")+        #expect(ShareCharacterChips.detailLabel(for: bob).contains("guild.\n\nStill"))         withExtendedLifetime(fixture) {}     } @@ -375,7 +487,7 @@ struct ShareWorkContextReadTests {         ])          let characters = try await context(fixture).characters-        #expect(characters == [ShareCharacter(name: "Alice")])+        #expect(characters == [ShareCharacter(id: Self.alice, name: "Alice")])         withExtendedLifetime(fixture) {}     } @@ -707,7 +819,7 @@ struct ShareWorkContextReadTests {         let context = try await context(fixture)         #expect(!context.hasCatchUp)         // …while the characters row is unaffected (Req 3).-        #expect(context.characters == [ShareCharacter(name: "Alice")])+        #expect(context.characters == [ShareCharacter(id: Self.alice, name: "Alice")])         withExtendedLifetime(fixture) {}     } @@ -1033,8 +1145,8 @@ struct ShareWorkContextLookupTests {         let basis = try await editBasis(fixture, includeWorkContext: true)         #expect(basis.entryID == Self.entryID)         #expect(basis.workContext.characters == [-            ShareCharacter(name: "Bob"),-            ShareCharacter(name: "alice", aliases: ["Al", "Ally"]),+            ShareCharacter(id: Self.bob, name: "Bob"),+            ShareCharacter(id: Self.alice, name: "alice", aliases: ["Al", "Ally"]),         ])         // The same list the standalone read gives for that work — one order,         // one projection.
Packages/AsterismCore/Tests/ConstellationKitTests/FlowLayoutTests.swift Added +202 / -0
diff --git a/Packages/AsterismCore/Tests/ConstellationKitTests/FlowLayoutTests.swift b/Packages/AsterismCore/Tests/ConstellationKitTests/FlowLayoutTests.swiftnew file mode 100644index 00000000..65b7019b--- /dev/null+++ b/Packages/AsterismCore/Tests/ConstellationKitTests/FlowLayoutTests.swift@@ -0,0 +1,202 @@+import CoreGraphics+import Testing++@testable import ConstellationKit++/// `FlowLayout`'s arithmetic, which moved out of the app target with the type+/// (`share-sheet-character-chips` Q3) and is a function of sizes, row width and+/// spacing so it can be asserted without a render.+///+/// Two claims. Content that fits its row is placed exactly where the app target+/// placed it — eight call sites depend on that — and an item wider than the row+/// is kept inside it instead of overflowing (Q10).+@Suite("FlowLayout arrangement")+struct FlowLayoutTests {++    private func arrangement(+        _ sizes: [CGSize], rowWidth: CGFloat, spacing: CGFloat = 8+    ) -> FlowLayout.Arrangement {+        FlowLayout.arrangement(of: sizes, rowWidth: rowWidth, spacing: spacing)+    }++    @Test("Nothing to lay out takes no space")+    func emptyIsZero() {+        let result = arrangement([], rowWidth: 200)+        #expect(result.positions.isEmpty)+        #expect(result.size == .zero)+    }++    @Test("Items that fit sit on one row, separated by the spacing")+    func oneRowKeepsTheSpacing() {+        let result = arrangement(+            [CGSize(width: 40, height: 20), CGSize(width: 60, height: 30)], rowWidth: 200)+        #expect(result.positions == [CGPoint(x: 0, y: 0), CGPoint(x: 48, y: 0)])+        // 40 + 8 + 60 wide, and as tall as the tallest item.+        #expect(result.size == CGSize(width: 108, height: 30))+    }++    @Test("An item that does not fit starts a new row under the tallest of the last")+    func wrapsAtTheRowWidth() {+        let result = arrangement(+            [+                CGSize(width: 60, height: 20),+                CGSize(width: 60, height: 30),+                CGSize(width: 60, height: 20),+            ],+            rowWidth: 130)+        // 60 + 8 + 60 = 128 fits; the third would need 196.+        #expect(result.positions == [+            CGPoint(x: 0, y: 0), CGPoint(x: 68, y: 0), CGPoint(x: 0, y: 38),+        ])+        #expect(result.size == CGSize(width: 128, height: 58))+    }++    @Test("An unconstrained proposal never wraps")+    func infiniteRowNeverWraps() {+        let result = arrangement(+            [CGSize(width: 400, height: 20), CGSize(width: 400, height: 20)],+            rowWidth: .infinity)+        #expect(result.positions == [CGPoint(x: 0, y: 0), CGPoint(x: 408, y: 0)])+        #expect(result.size.height == 20)+    }++    /// Q10: a name wider than the sheet used to run off the edge. The item is+    /// counted at the row width, so it takes the row it is on and the next item+    /// starts below it rather than beside it.+    @Test("An item wider than its row is clamped to the row and keeps the layout inside it")+    func oversizedItemIsClamped() {+        let result = arrangement(+            [CGSize(width: 400, height: 40), CGSize(width: 50, height: 20)], rowWidth: 130)+        #expect(result.positions == [CGPoint(x: 0, y: 0), CGPoint(x: 0, y: 48)])+        #expect(result.size == CGSize(width: 130, height: 68))+    }++    /// The clamp applies to the oversized item only: the item before it keeps+    /// its own row, and the measured width of everything that fits is untouched.+    @Test("An oversized item after a fitting one wraps rather than widening the row")+    func oversizedItemDoesNotWidenTheRow() {+        let result = arrangement(+            [CGSize(width: 50, height: 20), CGSize(width: 400, height: 40)], rowWidth: 130)+        #expect(result.positions == [CGPoint(x: 0, y: 0), CGPoint(x: 0, y: 28)])+        #expect(result.size == CGSize(width: 130, height: 68))+    }++    @Test("Rows are counted by how many times the items moved down")+    func rowCountFollowsTheWrapping() {+        #expect(arrangement([], rowWidth: 130).rowCount == 0)+        #expect(arrangement([CGSize(width: 60, height: 20)], rowWidth: 130).rowCount == 1)+        #expect(+            arrangement(Array(repeating: CGSize(width: 60, height: 20), count: 5), rowWidth: 130)+                .rowCount == 3)+    }+}++/// The row limit a collapsed chip row is drawn to (`share-sheet-character-chips`+/// Decision 1). The rule is one number — how many leading items are drawn — and+/// it is answered by laying candidates out, so the truncated row agrees with the+/// row the caller then draws.+@Suite("FlowLayout visible count")+struct FlowLayoutVisibleCountTests {++    private func visibleCount(+        _ sizes: [CGSize], expander: CGSize, rowWidth: CGFloat, spacing: CGFloat = 8,+        rowLimit: Int = 2+    ) -> Int {+        FlowLayout.visibleCount(+            of: sizes, expander: expander, rowWidth: rowWidth, spacing: spacing,+            rowLimit: rowLimit)+    }++    private func chips(_ count: Int, width: CGFloat) -> [CGSize] {+        Array(repeating: CGSize(width: width, height: 44), count: count)+    }++    @Test("A cast inside the limit keeps every chip and gives none up to an expander")+    func everythingFitsSoNothingIsHidden() {+        // Three 40 pt chips fit one 178 pt row; six fit two.+        #expect(+            visibleCount(chips(6, width: 40), expander: CGSize(width: 30, height: 44), rowWidth: 178)+                == 6)+    }++    @Test("Nothing to lay out, and a single chip, are answered without an expander")+    func emptyAndSingle() {+        #expect(visibleCount([], expander: CGSize(width: 30, height: 44), rowWidth: 178) == 0)+        #expect(+            visibleCount(chips(1, width: 40), expander: CGSize(width: 30, height: 44), rowWidth: 178)+                == 1)+        // A limit of no rows draws nothing at all.+        #expect(+            visibleCount(+                chips(6, width: 40), expander: CGSize(width: 30, height: 44), rowWidth: 178,+                rowLimit: 0) == 0)+    }++    /// The seventh chip is the one over the line, and the expander takes the+    /// room left over on row two rather than a chip's place.+    @Test("One chip over the limit is hidden, and the expander ends row two")+    func expanderTakesTheLeftoverRoom() {+        let expander = CGSize(width: 30, height: 44)+        #expect(visibleCount(chips(7, width: 40), expander: expander, rowWidth: 178) == 6)++        let drawn = FlowLayout.arrangement(+            of: chips(6, width: 40) + [expander], rowWidth: 178, spacing: 8)+        #expect(drawn.rowCount == 2)+        // Last item, second row, after the three chips that row holds.+        #expect(drawn.positions.last == CGPoint(x: 144, y: 52))+    }++    /// Room for the expander is reserved, so it can cost a chip that would+    /// otherwise have fitted: four 60 pt chips fill two 130 pt rows exactly, and+    /// the expander pushes the fourth out with the fifth.+    @Test("The expander costs a chip its place rather than wrapping to a third row")+    func expanderForcesOneMoreChipOut() {+        let expander = CGSize(width: 50, height: 44)+        #expect(+            FlowLayout.arrangement(of: chips(4, width: 60), rowWidth: 130, spacing: 8).rowCount+                == 2)+        #expect(visibleCount(chips(5, width: 60), expander: expander, rowWidth: 130) == 3)+        #expect(+            FlowLayout.arrangement(+                of: chips(3, width: 60) + [expander], rowWidth: 130, spacing: 8+            ).rowCount == 2)+    }++    /// Q10's clamp and the limit together: a name wider than the row takes a+    /// whole row, so two of them already fill the limit.+    @Test("A chip wider than the row takes a row of its own")+    func oversizedChips() {+        let expander = CGSize(width: 50, height: 44)+        #expect(visibleCount(chips(1, width: 400), expander: expander, rowWidth: 130) == 1)+        #expect(visibleCount(chips(2, width: 400), expander: expander, rowWidth: 130) == 2)+        // Three no longer fit, and the expander needs the second row for itself.+        #expect(visibleCount(chips(3, width: 400), expander: expander, rowWidth: 130) == 1)+    }++    /// An expander too wide to share a row with anything leaves the limit's last+    /// row to it; the answer is still the largest one that fits.+    @Test("An expander wider than the row leaves the last row to itself")+    func oversizedExpander() {+        #expect(+            visibleCount(chips(9, width: 40), expander: CGSize(width: 400, height: 44), rowWidth: 178)+                == 3)+    }++    /// The smallest answer: one row, and the first chip already fills it, so+    /// the expander is all the caller can draw.+    @Test("When the first chip and the expander cannot share the limit, nothing but the expander shows")+    func nothingButTheExpander() {+        #expect(+            visibleCount(+                chips(2, width: 400), expander: CGSize(width: 50, height: 44), rowWidth: 130,+                rowLimit: 1) == 0)+    }++    /// The answer follows what two rows hold, not how long the cast is.+    @Test("A very long cast hides everything past the limit")+    func longCast() {+        #expect(+            visibleCount(chips(200, width: 40), expander: CGSize(width: 30, height: 44), rowWidth: 178)+                == 6)+    }+}
docs/agent-notes/testing.md Modified +29 / -0
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 679c7e72..68d2ce58 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -1083,6 +1083,35 @@ header or the toolbar.  ## A locked screen fails the pending-capture suites and every UI runner +**Check the login keychain before the screen (2026-09-19, T-2317).** One+observation, but a clean one: overnight, `make test-core` failed 131 issues (the+three spool suites plus marker and store-metadata cells) and `make build-mac` /+`make install` died at `CodeSign … errSecInternalComponent`. The owner unlocked+the **login keychain and nothing else** — the screen stayed locked, and+`CGSSessionScreenIsLocked` read true throughout. Codesign then worked,+`make build-mac` and `make test-quick` without `SKIP_MAC` passed, and the three+spool suites passed in two consecutive `make test-core` runs. So the spool+signature below most likely follows the keychain's state rather than the+screen's; the two usually lock together overnight, which is why the screen took+the blame. `errSecInternalComponent` at CodeSign is the cheap tell.+`security show-keychain-info` ("no-timeout") and `security find-identity` (two+valid identities) both looked healthy while signing was failing, so neither+detects it. The keychain re-locked on its own within about twenty minutes.++What still failed with the keychain open and the screen locked: 11 and then 15+issues, all `Z_METADATA unreadable: database is locked`, in `Certification+paths`, `Advisory recorded store version`, `Marker generation 15` and `A+14.0.0-recorded store under the V15 plan`, the second run adding one cell each+in `MarkerContractTests` and `OpenerParityTests` (`no Z_METADATA row`). That+residue is neither the keychain nor the screen: it is a closing connection's+checkpoint lock refusing raw `Z_METADATA` readers that had no busy timeout,+which failed `make test-core` on `main` the same day and is fixed by #78 — see+"A raw SQLite reader needs a busy timeout" above and+`specs/bugfixes/store-metadata-read-database-is-locked/`. With that fix rebased+in, `make test-core` on this branch ran clean (zero issues) under the same+locked screen, so the keychain accounts for the whole of the spool signature+observed here.+ Check this before either of the two sections below it. On 2026-09-14 at 01:15, with the owner away and the Mac locked, `make test-core` failed 121 issues across `PendingCaptureDrainTests`, `PendingCaptureSpoolTests` and
specs/OVERVIEW.md Modified +14 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 70b96e80..878d96e4 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -46,6 +46,7 @@ | [Entry to Work Link](#entry-to-work-link) | 2026-09-14 | Done — all 5 tasks implemented 2026-09-14 to 2026-09-16; `make test-quick` green with no new warnings; every phone UI suite green on the branch across three partial `make test-ui` runs plus per-suite runs (the M4Scale trio excepted); `make test-ui-ipad` ran all 25 cases twice with the new case green both times and one different runner-crash failure each time, no single exit 0 (`verification-run.md`) | Smolspec. The work name on the entry detail screen becomes a control that opens the work's detail screen. One navigation-owned route: from a chapter on top of the Works path it drops the chapter and keeps what is beneath the work; from any other tab it shows that one work with nothing beneath it. Plain text stays for an unresolved or blank name and inside the Merge sheet. App-layer only; no Core, schema or repository change. | | [Update Schedule](#update-schedule) | 2026-09-15 | Done — all 30 tasks implemented 2026-09-16; `make test-core` (3,013 tests, zero known issues), `make test-quick`, `make test-ui-ipad` (27/27) green, `make test-performance-m4 RUNS=3` with the ten documented known issues and `todayPublication` at 0.80–0.88 s under a 2 s budget; the owner's device steps in `prerequisites.md` remain | Full spec. A reader-set list of release weekdays on every work under schema V15 with markers `"14"` → `"15"` and the archive at 14/15, set by seven toggles in the editor's Status card and shown on the meta line; the Recent tab becomes **Today**, a week strip over the day's releases and notes with its own typed route stack, inheriting Recent's banners, duplicate sections and search and superseding the 100-row cap; a Works filter finds works with no days. Manual only, no model. | | [Catch-Up Mode](#catch-up-mode) | 2026-09-16 | Done — both phases landed 2026-09-17; four pre-existing UI-test failures recorded in its verification run. Open: the full `make test-performance-m4` has not been run against the fixture with its 66 catch-up works | Smolspec. A fourth reading status, `catchingUp`, for a work the reader is behind on: it shows in a `Catching up` section on Today on the current day and future days whatever its work status or release days, while its release days are parked; no schema bump and no archive generation bump. |+| [Share Sheet Character Chips](#share-sheet-character-chips) | 2026-09-18 | In Progress | Smolspec (T-2317). The share sheets' cast text row becomes name-only chips; tapping one opens an inline card with that character's aliases and note, never facts. A cast past two chip rows collapses behind a `+N more` chip (Decision 1). Read-only, no schema change; `FlowLayout` moves into ConstellationKit. Supersedes `share-sheet-characters` Q2 (notes) and Q6 (no pills). |  --- @@ -772,3 +773,16 @@ Smolspec. A reader behind on a story has no reason to wait for its release day, - [tasks.md](catch-up-mode/tasks.md) - [decision_log.md](catch-up-mode/decision_log.md) - [verification-run.md](catch-up-mode/verification-run.md)++---++## Share Sheet Character Chips++**Created:** 2026-09-18 · **Status:** In Progress — tasks 1–4 and 6 implemented 2026-09-19 (T-2317); task 5, the full bar, is open. Q1–Q13 and Decision 1 in the log.++Smolspec. Both capture sheets draw the work's cast as chips carrying the main name only, in the `character-ranking` order and under the conditions the text row shows today. Tapping a chip opens one inline card beneath the row with the character's name, aliases and whole note; one card at a time, none open on appear, and a character with neither aliases nor a note still opens a card saying so (Q4, Q5). Facts stay out of the extension, so this supersedes only the notes half of `share-sheet-characters` Q2, and Q6's text row with it; a cast past two chip rows is capped at two with a `+N more` expander whose hidden chips are not rendered (Decision 1, superseding Q2's acceptance of the height; Q1 stands). `ShareCharacter` gains a required `id` and a trimmed `note` with no extra fetch (Q6); the open/close rules and every accessibility string are tested values in `AsterismCore` (Q9, Q11). `FlowLayout` moves from the app target into ConstellationKit and clamps an item wider than its row (Q3, Q10). No schema, archive or CloudKit change; no places row (Q8).++- [smolspec.md](share-sheet-character-chips/smolspec.md)+- [tasks.md](share-sheet-character-chips/tasks.md)+- [decision_log.md](share-sheet-character-chips/decision_log.md)+- [implementation.md](share-sheet-character-chips/implementation.md)
specs/share-sheet-character-chips/decision_log.md Added +133 / -0
diff --git a/specs/share-sheet-character-chips/decision_log.md b/specs/share-sheet-character-chips/decision_log.mdnew file mode 100644index 00000000..7bee4833--- /dev/null+++ b/specs/share-sheet-character-chips/decision_log.md@@ -0,0 +1,133 @@+# Decision Log: Share Sheet Character Chips++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-09-18 | The sheet shows a character's note; this supersedes the notes half of `share-sheet-characters` Q2. Facts stay excluded | Owner request (T-2317). Q2's spoiler argument is about facts, which cite entries anywhere in the work; a note is the reader's own text. A card is closed until tapped, so the note costs no height until asked for |+| Q2 | 2026-09-18 | Chips of the main name with aliases inside the detail; this supersedes `share-sheet-characters` Q6 (one text row, no pills). ~~The cast row gets taller and that is accepted~~ — the height acceptance is **superseded by Decision 1**; the chips themselves stand | Owner request (T-2317). Q6 gave three reasons: `FlowLayout` lived in the app target (Q3 removes it), the sheet's other metadata is prefixed text rows, and a text row is the shortest presentation of a full list. The last still holds — 44 pt chips take more height than lines of text — and is outweighed by the row becoming something the reader can tap. On the phone the height turned out not to be acceptable after all, which Decision 1 answers |+| Q3 | 2026-09-18 | `FlowLayout` moves from the app target into ConstellationKit rather than being copied into the extension | The extension already links ConstellationKit; the type has no app dependency; a second copy would drift |+| Q4 | 2026-09-18 | The detail is an inline card under the chip row, one open at a time, closed on appear — the work page's `recordCollection` interaction | The extension cannot open the app (`share-sheet-last-note` Q1), a popover behaves differently on iOS and Mac inside a share sheet, and the work page already taught the reader this interaction |+| Q5 | 2026-09-18 | Every chip is tappable; a character with no aliases and no note opens a card reading `No aliases or notes yet.` | A chip that ignores a tap reads as broken, and marking some chips as inert needs a second visual style for no gain |+| Q6 | 2026-09-18 | `ShareCharacter` gains `id` (the record group's UUID), required in its initialiser; the open chip is tracked by it | Two characters may share a name, and an index would point at the wrong chip if the list changed under an open card. No default, because a defaulted `UUID()` would compile and silently break every equality assertion in the tests |+| Q7 | 2026-09-18 | `ShareCharacterRow.text(for:)` and its test suite are removed, not kept beside the chips | Nothing in production would call it; tests that used it as a shorthand compare the character list directly |+| Q8 | 2026-09-18 | No places row and no generic record chip view | `place-extraction` Q5 leaves the places row to its own smolspec; an abstraction with one conformance is speculation, and the view is small enough to generalise when the second one arrives |+| Q9 | 2026-09-18 | The open/close rules are pure functions in `AsterismCore`; the view's `@State` changes only through them | The extension has no XCUITest coverage by decision, so a rule that lives only in a view is a rule nothing checks (sharesheet-polish Q6) |+| Q10 | 2026-09-18 | `FlowLayout` clamps a subview wider than its row to the row width; every other subview keeps its `.unspecified` proposal | A long name at `.accessibility5` would otherwise overflow the sheet. Clamping in the layout fixes the same overflow for the app's eight users and touches only content that overflows today; clamping each chip instead would need the row width measured in every caller |+| Q11 | 2026-09-18 | The chip row is a VoiceOver group labelled `Characters`, the card is one combined element, and focus moves to the card when it opens | Dropping the `Characters:` prefix would leave bare names with no context, and the card renders after the whole chip row, so without a focus move a reader swipes past every chip to reach it. The work page has neither; it is not the model for this part |+| Q12 | 2026-09-18 | `projectedMetadataSection` gains `.accessibilityElement(children: .contain)` | The chips are the first controls inside that identified container; without it the identifier collapses them into one element (`docs/agent-notes/composed-teaching-ui.md`) |+| Q13 | 2026-09-18 | Chip identifiers are indexed (`<prefix>.chip.<index>`) although open state is tracked by id | An identifier exists for a test to address the nth chip in a deterministic order; a UUID in an identifier is unaddressable |+| Q14 | 2026-09-19 | A chip's hit target is `minHitTarget` in both directions: the height comes from the pill recipe, the width is a leading-aligned `minWidth` frame on the chip label | The recipe only sets a height, so a two-letter name was a pill narrower than 44 pt; the work page has the same gap and is left alone here (pre-push review) |+| Q15 | 2026-09-19 | The expander chips use `.count`, the kind the card's alias pills already use, and every chip label sets `monospacedDigit()` | Cyan reads as "not a name" beside violet chips and no new recipe is needed; room for `+N more` is measured at the cast's full count, and SF's proportional digits could make a smaller number wider than the room kept for it — a one-point overrun is a third row (pre-push review) |+| Q16 | 2026-09-19 | When what two rows hold changes without a tap — rotation, text size, a refresh — the open chip is re-derived through `openAfterCollapse` and `isExpanded` resets once the cast fits | Otherwise a card hidden by the reflow comes back unasked on the next expand, and a cast that overflows again arrives already expanded, against Decision 1's "opens collapsed" (pre-push review) |++---++## Decision 1: Cap the collapsed cast at two chip rows++**Date**: 2026-09-19+**Status**: accepted++### Context++Q2 accepted that chips take more height than the text row they replaced, on+the reasoning that the sheet scrolls and no card is open until the reader taps.+The owner installed the build and rejected that: "The block of names is too big+like this. We need something to expand it when there are more than 2 lines of+names."++The sheet's job is a chapter note. Everything above the note editor is context,+and a work with a dozen named characters put three or four rows of 44 pt chips+between the reader and the field they came to type in — worse at large Dynamic+Type sizes, where a single name can take most of a row. The cast is ranked by+prominence (`character-ranking`), so the names that matter most are already the+first ones; what is past the second row is the tail.++### Decision++A collapsed chip row draws at most two rows. Where the cast does not fit, the+last item of the second row is an expander chip reading `+N more`, and the room+that chip needs is reserved before the count is taken, so it can never wrap onto+a third row. Tapping it draws the whole cast followed by a `Show fewer` chip.+The sheet always opens collapsed and nothing is persisted. The characters behind+the expander are **not rendered at all**: VoiceOver does not reach them and a tap+cannot hit them. This supersedes Q2's acceptance of the height; the chips+themselves are unaffected, and a cast that fits two rows is drawn exactly as it+was before.++### Rationale++Two rows is the smallest limit that still shows a useful cast — at body text on+a phone it is six to eight names — and it is what the owner asked for in those+words. A count on the chip rather than a bare `Show all` tells the reader+whether one name is behind it or thirty, which is the difference between+tapping and not bothering.++Not rendering the hidden chips is the part that costs something. The view has to+know each chip's width to know how many two rows hold, and the chips it decides+not to draw are the ones it cannot measure from what it drew, so it keeps a+hidden, accessibility-hidden, non-hit-testable measuring copy of the whole cast+in the background of the row. That is the price of hidden names being genuinely+absent rather than merely invisible, and it is worth paying: a chip under a clip+mask is still a button the reader's thumb and VoiceOver both find.++The count itself is a pure function in ConstellationKit, built on+`FlowLayout.arrangement` rather than on a second copy of the wrap arithmetic,+which is the only way the truncated row is guaranteed to agree with the row the+view then draws. The strings and the open-card rule are values in `AsterismCore`+beside the rest of the row's rules (Q9), because the extension has no XCUITest+coverage.++### Alternatives Considered++- **Clip and expand**: render the whole row and clamp its height, expanding the+  clamp on a tap - Rejected because clipped chips stay in the accessibility tree+  and stay tappable; a VoiceOver reader would swipe through names they cannot+  see, and a thumb at the clip edge would open a card for a chip that is not+  there. It would also make the `+N` count unavailable, since nothing would have+  counted what was cut.+- **A row limit inside `FlowLayout` itself**: teach the layout to stop after+  *n* rows - Rejected because a `Layout` that drops subviews still has them in+  the view tree — `Layout` chooses placement, not existence — so it solves+  neither the accessibility nor the hit-testing half. The eight app call sites+  would also inherit a parameter none of them wants.+- **A `Show all` text button under the row**: no count, no chip - Rejected+  because it adds a row of its own under a row that is already too tall, and it+  says nothing about how much is behind it. The expander as the row's last chip+  costs no extra height at all.+- **A single scrolling row**: one horizontal row the reader swipes - Rejected+  because a horizontal scroller inside the sheet's vertical scroll fights the+  gesture, hides that there is anything to scroll to, and would put the cast on+  a different interaction model from every other chip row in the app.+- **Leave it and let the sheet scroll** (Q2's position) - Rejected by the owner+  after using it.++### Consequences++**Positive:**+- The note editor stays near the top of the sheet whatever the cast size.+- Hidden names are absent from the accessibility tree and from hit testing, not+  merely invisible.+- The limit is a tested value, not a number inside a view, so it holds at+  `.accessibility5` and for a name wider than the row without a second code path.+- A cast that fits two rows is untouched, so the common case did not change.++**Negative:**+- The whole cast is laid out twice — once hidden to measure, once to draw — so a+  long cast costs an extra layout pass per row-width change.+- The collapsed count is only known after that pass, so the first frame draws no+  chips. Drawing the full cast for that frame would be the flash the change+  exists to remove, so the empty frame is the lesser of the two.+- The reserved expander width is measured at `+{cast.count} more`, an upper+  bound, so a row can occasionally be one digit's width more conservative than it+  needed to be.++### Impact++`FlowLayout` (`Arrangement.rowCount`, `visibleCount(of:expander:rowWidth:spacing:rowLimit:)`),+`ShareCharacterChips` (`visibleRowLimit`, `expanderText`, `expanderLabel`,+`collapseText`, `collapseLabel`, `openAfterCollapse`) and+`ShareCharacterChipsView`. Both capture sheets get the behaviour without a call+site change.++---
specs/share-sheet-character-chips/implementation.md Added +168 / -0
diff --git a/specs/share-sheet-character-chips/implementation.md b/specs/share-sheet-character-chips/implementation.mdnew file mode 100644index 00000000..0e484461--- /dev/null+++ b/specs/share-sheet-character-chips/implementation.md@@ -0,0 +1,168 @@+# Implementation: Share Sheet Character Chips (T-2317)++An explanation of what was built, at three levels, written during the pre-push+review as a check that every requirement can be explained from the code.++## Beginner Level++### What Changed++When you share a chapter into Asterism, the share sheet used to list the work's+characters as one line of text: `Characters: Alice (Al, Ally), Bob`. It now+shows each character as a small rounded button (a "chip") with just the main+name. Tap a chip and a card opens underneath with that character's other names+and the note you wrote about them. Tap it again and the card closes.++If a work has a lot of characters, only two rows of chips are shown, and the+last chip reads `+12 more`. Tapping it shows everyone, with a `Show fewer` chip+at the end.++### Why It Matters++The share sheet is where a reader writes a note about the chapter they just+read. Until now they could not check what they had written about a character+without leaving the sheet. Now it is one tap away, and a long cast does not push+the note field off the screen.++### Key Concepts++- **Share extension**: the small Asterism window that appears inside another+  app when you press Share. It is a separate, memory-limited program that can+  read the library but keeps its work small.+- **Chip**: a pill-shaped button holding a short label.+- **Wrapping layout**: chips are placed left to right and start a new row when+  they run out of room, like words in a paragraph.+- **VoiceOver**: the iPhone screen reader. Everything here has a spoken label,+  and the chips that are hidden behind `+N more` are really absent, so the+  screen reader cannot land on something the reader cannot see.++---++## Intermediate Level++### Changes Overview++- `ShareWorkContext.swift` (AsterismCore): `ShareCharacter` is `Identifiable`+  with a required `id` (the record group's UUID) and a `note`, trimmed by the+  read. `ShareCharacterChips` replaces `ShareCharacterRow` and holds every rule+  and string: `toggled`, `openCharacter`, `openAfterCollapse`,+  `visibleRowLimit`, `groupLabel`, `chipValue`, `detailLabel`,+  `emptyDetailText`, `expanderText/Label`, `collapseText/Label`.+- `FlowLayout.swift` (ConstellationKit): moved out of the app target and made+  public. Its arithmetic is the pure `arrangement(of:rowWidth:spacing:)`;+  `visibleCount(of:expander:rowWidth:spacing:rowLimit:)` answers how many+  leading items fit a row limit with room kept for an expander. A subview wider+  than the row is proposed the row width (Q10).+- `ShareCharacterChipsView.swift` (share extension): replaces+  `ShareCharactersRow.swift` on `CaptureView` and `ReShareCaptureView`.+- Tests: `ShareWorkContextTests`, `CaptureStateTests`,+  `ReShareExtensionUITests`, new `FlowLayoutTests`.++### Implementation Approach++The project's rule is that views lay out and core decides (sharesheet-polish+Q6). So the view's only state is `openID`, `isExpanded` and three measurements;+every transition goes through a `ShareCharacterChips` function that has a unit+test.++The two-row cap needs chip widths, and the chips it hides are the ones it+cannot measure from what it drew. A hidden copy of the whole cast plus one+expander sits in the row's `.background`, excluded from accessibility and hit+testing, reporting sizes through `onGeometryChange`. Those sizes, the row width+and the limit go to `FlowLayout.visibleCount`, which lays candidate rows out+with the same `arrangement` the real layout uses, so the count and the drawn+row cannot disagree.++### Trade-offs++- Clipping a fully drawn row was simpler and rejected: clipped chips stay in the+  accessibility tree and stay tappable (Decision 1).+- A row limit inside the `Layout` was rejected: a layout cannot tell the view+  how many it hid, so `+N` would have no N.+- The first frame draws no chips, rather than the whole cast followed by a+  collapse.+- The expander is measured at the cast's full count with fixed-width digits, a+  slight over-reservation that guarantees it never wraps to a third row (Q15).++---++## Expert Level++### Technical Deep Dive++- **Identity.** `openID` is a record-group UUID, not an index, so two characters+  with one name stay distinct and a refresh that reorders the cast keeps the+  right card open. Store-derived ids also keep `CaptureViewModel`'s+  `workContext != fetched` check stable: an identical re-read compares equal.+- **`visibleCount`.** Returns `sizes.count` when the cast fits; otherwise walks+  `k` upward while `first k + expander` stays inside the limit. Adding a leading+  item never removes a row under greedy wrapping, so the first failure is+  final and the cost follows what two rows hold, not the cast size. `rowLimit+  <= 0` and an oversized first chip on a one-row limit both answer 0.+- **Clamp.** `measured` re-measures only a subview whose ideal width exceeds+  the row, and only that subview is placed with a width proposal. Everything+  else keeps `.unspecified`, which `WorkDetailView`'s meta line depends on.+- **Reflow.** `onChange(of: collapsedCount)` re-derives `openID` through+  `openAfterCollapse` and resets `isExpanded` once the cast fits (Q16), so a+  rotation or text-size change cannot leave a card open under a chip that is no+  longer drawn.+- **Read.** No extra fetch: the map already held `group.presentedContent`.+  `RecordRanking.rankGroups` still drops each group's facts before the next.+  The pending-capture drain still reads no characters.++### Architecture Impact++`FlowLayout` is now ConstellationKit public API with two pure entry points;+the app's fifteen uses across eight views are untouched for content that fits.+The extension gains no new dependency (it already linked ConstellationKit) and+still does not link `AsterismIntelligence`. No schema, archive or CloudKit+change.++### Potential Issues++- The extension has no XCUITest coverage by decision, so the rendered layout —+  two rows at `.accessibility5`, the no-chip first frame, the 44 pt minimum+  width — rests on pure tests plus a look on a device.+- An app `Label` wider than its whole row is now clamped where it used to+  overflow. The smolspec accepts this as fixing a defect; if a `Label` resolves+  to icon-only when given a width, that case would look different rather than+  merely truncated.+- A refresh that adds a character leaves `collapsedCount` nil for one pass+  until the new chip is measured, so the row is briefly empty. The share read+  arrives as one batch, which is why this was left alone.++---++## Completeness Assessment++**Fully implemented**++- Name-only chips on both sheets, in the read's order, under the text row's+  conditions; nothing drawn for an empty cast or failed read.+- One inline card: toggle, replace, none open on appear, closes when its+  character leaves the cast or its chip is hidden.+- Card content: name, aliases in order, whole note with line breaks, and+  `No aliases or notes yet.` for neither. No facts, no torn marker, no+  navigation.+- Note trimmed by the read; no extra fetch; drain reads no characters.+- Selected pill style; one-line truncated chips inside the row; `minHitTarget`+  in both directions (Q14).+- VoiceOver group, per-chip button with value, combined card label, focus move+  on open; all strings and rules as tested core values.+- Leaf identifiers `.chip.<index>`, `.detail`, `.more`, `.fewer`; the bare+  prefix identifier is gone.+- Two-row cap with `+N more` / `Show fewer`, hidden chips not rendered+  (Decision 1).+- `FlowLayout` public in ConstellationKit with fitting content unchanged.++**Verified by hand only**++- The rendered layout on a phone (owner, 2026-09-19: "looking good"). Previews+  compile and have not been looked at on a canvas.++**Open**++- Task 5: the full bar. `make test-core` and `make test-quick` pass. Four UI+  tests fail on `make test-ui` / `make test-ui-ipad`, and the same four fail on+  `main` without this change (three Works options-menu scroll journeys, one+  wide-layout window switch).
specs/share-sheet-character-chips/smolspec.md Added +55 / -0
diff --git a/specs/share-sheet-character-chips/smolspec.md b/specs/share-sheet-character-chips/smolspec.mdnew file mode 100644index 00000000..39cccc9d--- /dev/null+++ b/specs/share-sheet-character-chips/smolspec.md@@ -0,0 +1,55 @@+# Share Sheet Character Chips++**Ticket:** T-2317++## Overview++The share extension's two capture sheets list a work's cast as one text row, `Characters: Alice (Al, Ally), Bob` (`specs/share-sheet-characters/`). A reader writing a chapter note cannot see what they wrote down about a character without leaving the sheet. This change draws the cast as chips carrying the main name only; tapping a chip opens one inline detail card with that character's aliases and note. Facts stay out of the extension. The change is read-only: two more fields on a value the sheet already receives, and a view.++## Requirements++- The system MUST draw each character of the work as one chip showing the character's name only, in a wrapping row, on both the new-capture sheet and the re-share sheet, in the places the text row occupies today. Aliases MUST NOT appear on a chip.+- The chips MUST keep the order the read already produces (the `character-ranking` prominence order) and MUST appear under exactly the conditions the text row appears under today; with no characters, or a failed read, the sheet MUST draw no character-related element.+- Tapping a chip MUST open a detail card directly beneath the chip row. Tapping the open chip MUST close it; tapping another chip MUST replace it. At most one card is open, none is open when the sheet appears, and a refresh that drops the open character from the cast MUST leave no card open.+- The card MUST show the character's name, then its aliases (in the order the read holds them) when it has any, then its whole note with its line breaks when it has one. A missing half is omitted. A character with neither MUST still open a card, which reads `No aliases or notes yet.` under the name.+- The card MUST NOT show facts, fact counts, citations, or a torn marker, and MUST NOT be a sheet, popover or navigation push. Nothing in it may open the app or edit the character.+- The note MUST be trimmed of surrounding whitespace by the read; aliases are shown as the read holds them.+- The open chip MUST use the selected pill style the work page uses for its open record.+- A cast that needs more than two chip rows MUST be drawn to two, with the last item of the second row an expander chip reading `+N more`, where `N` is the number of characters not drawn. Room for that chip MUST be reserved, so it never wraps onto a third row. A cast that fits two rows MUST look and behave exactly as it did before the limit existed: no expander, no extra chip.+- The characters the expander stands in for MUST NOT be rendered: VoiceOver MUST NOT reach them and a tap MUST NOT hit them. Clipping a fully rendered row is not an acceptable implementation.+- Tapping the expander MUST show every character, followed by a trailing `Show fewer` chip that collapses the row again. The sheet MUST always open collapsed and MUST NOT persist the expanded state. Collapsing MUST close the detail card when the collapse hides the open character's chip; a refresh that changes the cast keeps every rule above.+- The expander chips MUST be visually distinct from a character chip, MUST meet `AsterismLayout.minHitTarget`, and MUST carry the identifiers `<prefix>.more` and `<prefix>.fewer`. VoiceOver MUST reach each as a button inside the `Characters` group, labelled `Show N more characters` and `Show fewer characters`. Expanding and collapsing MUST animate as the card does.+- A chip whose name is wider than the row MUST stay inside the row on one truncated line, and the card's name and note MUST wrap; this holds at the `.accessibility5` Dynamic Type size on both sheets. Chips MUST meet `AsterismLayout.minHitTarget`.+- The read MUST stay a shared-lock, never-writing read with no additional fetch, and MUST NOT carry facts into the extension. The pending-capture drain MUST still read no characters.+- VoiceOver MUST announce the chip row as a group labelled `Characters`. Each chip MUST be a separately reachable button labelled with the character's name, with the value `Details shown` or `Details hidden`. The card MUST be one element labelled `<name>. Also known as <alias>, <alias>. <note>` (absent halves omitted; `<name>. No aliases or notes yet.` for neither), and VoiceOver focus MUST move to it when it opens.+- Those strings, and the open/close rules above, MUST be produced in `AsterismCore` as tested pure values (sharesheet-polish Q6); the views only lay them out.+- Accessibility identifiers MUST sit on leaves: `<prefix>.chip.<index>` per chip (index in display order, so a test can address the nth chip; the open chip is tracked by id, not by this index) and `<prefix>.detail` on the card, where `<prefix>` is `capture.characters` or `reshare.characters`. The bare `<prefix>` identifier the text row carries today goes away; nothing references it.+- Every app screen that uses the wrapping chip layout MUST look and behave as before wherever its content fits the row.++## Implementation Approach++- **Value** — `Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift`: `ShareCharacter` becomes `Identifiable` with `init(id: UUID, name: String, aliases: [String] = [], note: String = "")`. `id` is the record group's id and has no default, so the compiler finds every fixture. The map at `:227-233` already holds `group.presentedContent`; it passes `group.id` and the note trimmed as `workNote` is at `:247-248`. `ShareCharacter` is not `Codable` and crosses no persisted format. Store-derived ids keep `CaptureViewModel.swift:348`'s `workContext != fetched` change check stable.+- **Rules and strings in core** — replace `ShareCharacterRow` (unused once the row goes) with `ShareCharacterChips` in the same file, on the pattern of `ShareLastNoteRow` (`:126`): `toggled(open: UUID?, tapped: UUID) -> UUID?`, `openCharacter(_ id: UUID?, in: [ShareCharacter]) -> ShareCharacter?`, `groupLabel`, `chipValue(isOpen:)`, `detailLabel(for:)`, `emptyDetailText`. The two-row limit adds `visibleRowLimit`, `expanderText(hidden:)`, `expanderLabel(hidden:)`, `collapseText`, `collapseLabel` and `openAfterCollapse(open:visible:)` — the last being `openCharacter` against the collapsed list, so the card goes with the chip it belongs to. `expanderLabel` pluralises inline; `Pluralisation` lives in the app target and the extension cannot reach it.+- **The row limit** — `FlowLayout.Arrangement` gains `rowCount` (a `y` that moved is a new row) and `FlowLayout` gains `visibleCount(of:expander:rowWidth:spacing:rowLimit:)`: `sizes.count` when everything already fits, otherwise the largest `k` for which the first `k` sizes *plus* the expander lay out inside the limit, found by walking `k` down from `sizes.count - 1`. It answers by laying candidates out through `arrangement` rather than re-deriving the wrap arithmetic, so the truncated row agrees with the row the view then draws.+- **Layout** — move `FlowLayout` (`Asterism/Asterism/Views/TeachingComponents.swift:98-140`) into a new `Packages/AsterismCore/Sources/ConstellationKit/FlowLayout.swift`, `public`, keeping `spacing` and its default of 8. Both extension targets already link ConstellationKit (`project.pbxproj:150-168`), and its eight app users (`WorkDetailView`, `CharacterEditorView`, `CreditEditorView`, `ComposedTeachingView`, `ComposedURLDetailsEditor`, `LinkTypeEntryView`, `ReleaseDaysPresentation`, `WorksView`) already import it. One change rides along: a subview whose ideal width exceeds the row is measured and placed with the row width as its proposal, so it truncates or wraps instead of overflowing. Every other subview keeps its `.unspecified` proposal, which `WorkDetailView.swift:759-769` relies on. The position arithmetic becomes a function of sizes, row width and spacing so it can be tested (decision log Q3, Q10).+- **Measuring the row** — the view needs every chip's width to know how many two rows hold, and the chips it decides not to draw are exactly the ones it cannot measure from what it drew. `ShareCharacterChipsView` therefore keeps a measuring copy of the whole cast, plus one expander chip, in a `.background` of the chip row: same `FlowLayout`, same spacing, `.hidden()`, `.accessibilityHidden(true)`, `.allowsHitTesting(false)`, each copy reporting through `onGeometryChange`. The row width comes from `onGeometryChange` on the row's own `maxWidth: .infinity` frame. The expander is measured at `expanderText(hidden: characters.count)` — the widest count this cast can produce — so the reserved room is never short. Nothing in the measuring layer depends on the state it sets, so the layout settles in one pass; before it has, the row draws nothing rather than the whole cast.+- **View** — replace `Asterism/AsterismShareExtension/ShareCharactersRow.swift` with `ShareCharacterChipsView(characters:identifierPrefix:)`, aligned leading at full width. `@State private var isExpanded` is flipped only by the two expander chips, inside `withAnimation(.snappy)`, and the collapse re-derives `openID` through `openAfterCollapse`. Both expander chips are `.buttonStyle(.plain)` and `.constellationPill(.count)` — cyan, because they name no character and violet is what a name looks like here. A top-aligned `person.2` glyph sits beside `FlowLayout(spacing: 8)` of `.buttonStyle(.plain)` buttons, each a one-line tail-truncated name styled `.constellationPill(isOpen ? .selectedTypeTag : .typeTag)`. `@State private var openID: UUID?` changes only through `ShareCharacterChips.toggled`, inside `withAnimation(.snappy)` as the work page does. The card beneath uses `constellationCard`: name in the work page's heading style, alias pills (`.constellationPill(.count)`) in a `FlowLayout`, note in `AsterismColors.noteText`; it is one `.combine` accessibility element (it holds no controls) and takes `@AccessibilityFocusState` on open. The chip row is `.accessibilityElement(children: .contain)` with `groupLabel`. Interaction reference: `recordCollection` / `recordPill` / `recordDetailCard`, `Asterism/Asterism/Views/WorkDetailView.swift:1911-2077`, minus facts, editing and torn handling. The state resets when a sheet swaps its ready and failed content; that is accepted.+- **Call sites** — `CaptureView.swift:312-318` and `ReShareCaptureView.swift:131-136` swap `if let text = ShareCharacterRow.text(for:)` for `if !characters.isEmpty`. On the new-capture sheet the chips are the first controls inside `projectedMetadataSection`, whose identifier (`CaptureView.swift:339`) would collapse them into one element: add `.accessibilityElement(children: .contain)` before it (`docs/agent-notes/composed-teaching-ui.md:101-106`). The re-share site has no such wrapper. Rewrite the comments that describe a text row: both call sites, `ShareCatchUpSection.swift:71`, `ShareWorkContext.swift:22-27` and `:223-226`, `RecordRanking.swift:275-281`.+- **Tests** — `Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift`: replace the `ShareCharacterRowTests` suite (`:17-50`) with `ShareCharacterChips` tests — the toggle truth table, `openCharacter` for a present, absent and nil id, and every string for aliases-and-note, aliases only (order kept), note only, neither; the read carries the group id and trimmed note for a plain, a torn and a note-less character, two same-named characters keep distinct ids, and an identical re-read compares equal. Fixtures across that file, `CaptureStateTests.swift` and `ReShareExtensionUITests.swift` (about 45 constructions) take fixed UUIDs, and read-output expectations use the seeded record ids; the three `ShareCharacterRow.text` assertions (`CaptureStateTests.swift:703`, `ReShareExtensionUITests.swift:282,302`) compare `workContext.characters`. The drain's no-characters default stays pinned by the existing test at `ShareWorkContextTests.swift:1099`. `Packages/AsterismCore/Tests/ConstellationKitTests/`: the arrangement function wraps at the row width, clamps an oversized item to it, places fitting items as before, and counts its rows; `visibleCount` answers a cast inside the limit with all of it, hides the one chip over the line and ends row two with the expander, gives a chip's place up to the expander rather than wrapping to a third row, handles a chip and an expander each wider than the row, and answers zero and one character and a zero row limit. `#Preview`s for the view inside a `constellationField` card and free-standing: a cast that fits, a long cast collapsed, the same expanded, an open card with a multi-line note, a name wider than the row, and `.accessibility5` collapsed and with a card (pattern: `ShareCatchUpSection.swift:134-162`). A DEBUG-only `expanded:` initialiser sits beside the DEBUG-only `openFor:` one, for the same reason: a preview cannot tap a chip. The extension has no XCUITest coverage by decision, so nothing asserts the rendered layout — the previews are compiled, and looked at by hand.+- **Bar** — `make test-core`, `make test-quick` without `SKIP_MAC` (nothing else compiles the Mac extension), `make test-ui` and `make test-ui-ipad` once for the layout move; no new compiler warnings; `make verify-identity` passes; a `CHANGELOG.md` entry under Unreleased.+- **Out of Scope** — facts or any spoiler boundary; editing, combining or resolving a character from the sheet; opening the app; a places row or any generic record chip view (decision log Q8); the order, the conditions under which the cast shows, or the read's trigger and guards; remembering the open chip across presentations; XCUITest coverage of the extension; any schema, archive or CloudKit change.++## Risks and Assumptions++- Risk: the cast takes more height than the text row did — each chip is 44 pt tall, so a twelve-name cast is roughly three chip rows against two or three lines of text — and pushes the note editor down before any card opens. | Mitigation: Q2 accepted that height and the owner rejected it on the phone; a collapsed cast is now capped at two rows with a `+N more` chip, and Decision 1 supersedes that half of Q2. The rest holds: the sheet scrolls, and no card is open until the reader taps.+- Risk: the row needs a measuring copy of the whole cast to know what fits, so every chip is laid out even where most are not drawn. | Mitigation: a cast is tens of names at most, the copies are `.hidden()` so nothing rasterises, and the alternative — clipping a drawn row — leaves the hidden names reachable by VoiceOver and by a thumb.+- Risk: the collapsed count is only known after a measuring pass, so the first frame has nothing to draw. | Mitigation: the row draws nothing for that frame rather than the whole cast, which is the flash worth avoiding; the measuring layer depends on nothing it sets, so the pass settles immediately.+- Risk: the new-capture read lands one refresh after first render, so chips appear under a reader who is already typing. | Mitigation: unchanged from today's row and accepted there; the row sits above the note field.+- Risk: the clamp changes how an app screen lays out a chip wider than its row. | Mitigation: such a chip overflows its container today, which is a defect; fitting content is untouched and the arrangement tests pin that.+- Risk: a note is reader-authored and could itself describe later chapters. | Mitigation: accepted (decision log Q1); it is the reader's own text.+- Assumption: `presentedContent.note` is what the work page shows for the character, torn or not, so sheet and work page agree except for surrounding whitespace.+- Assumption: one `String` note per character is negligible against the extension's memory cap; `RecordRanking.rankGroups` still drops each character's facts before the next (`character-ranking` Q43 as amended).+- Prerequisite: none; `share-sheet-characters`, `share-sheet-last-note` and `character-ranking` are on `main`.++## Escalation Note+This change was scoped as a smolspec. If implementation reveals ambiguity only the user can resolve, an irreversible boundary (public API, persisted schema, auth path), or a contested architectural choice, stop and escalate to the full spec workflow rather than deciding it inline.
specs/share-sheet-character-chips/tasks.md Added +49 / -0
diff --git a/specs/share-sheet-character-chips/tasks.md b/specs/share-sheet-character-chips/tasks.mdnew file mode 100644index 00000000..9121c1a0--- /dev/null+++ b/specs/share-sheet-character-chips/tasks.md@@ -0,0 +1,49 @@+---+references:+    - specs/share-sheet-character-chips/smolspec.md+    - specs/share-sheet-character-chips/decision_log.md+---+# Share Sheet Character Chips - T-2317++- [x] 1. The wrapping chip layout is public in ConstellationKit and keeps an oversized item inside its row <!-- id:yxy3fhq -->+  - Spec: smolspec.md Layout bullet; decision log Q3, Q10.+  - FlowLayout moves out of the app target with no change for content that fits its row; an item wider than the row is proposed the row width.+  - Tests first, in ConstellationKitTests: wraps at the row width, clamps an oversized item, places fitting items exactly as before.+  - Done when make test-core passes and make test-quick builds the app and both extensions with the eight app call sites unchanged.++- [x] 2. The share read carries each character id and trimmed note <!-- id:yxy3fho -->+  - Spec: smolspec.md Value bullet; decision log Q6.+  - ShareCharacter is Identifiable with a required id and a note; no extra fetch, no facts.+  - Tests first: group id and trimmed note for a plain, a torn and a note-less character; two same-named characters keep distinct ids; an identical re-read compares equal.+  - Every ShareCharacter fixture across ShareWorkContextTests, CaptureStateTests and ReShareExtensionUITests takes a fixed UUID, and read-output expectations use the seeded record ids. The existing drain test stays green.+  - Done when make test-core passes.++- [x] 3. Chip open and close rules and every accessibility string are tested core values <!-- id:yxy3fhp -->+  - Spec: smolspec.md Rules and strings bullet and the VoiceOver requirement; decision log Q5, Q9, Q11.+  - ShareCharacterChips is added beside the existing text builder, which stays until task 4.+  - Tests first: the toggle truth table; the open character for a present, absent and nil id; the exact strings for aliases and note, aliases only with order kept, note only, and neither.+  - Done when make test-core passes.+  - Blocked-by: yxy3fho (The share read carries each character id and trimmed note)++- [x] 4. Both capture sheets draw the cast as chips with one inline detail card <!-- id:yxy3fhr -->+  - Spec: smolspec.md View and Call sites bullets; decision log Q2, Q4, Q12, Q13.+  - ShareCharacterChipsView replaces ShareCharactersRow on both sheets; the new-capture metadata card contains its children for accessibility; identifiers sit on leaves; VoiceOver focus moves to the card on open.+  - ShareCharacterRow and its test suite are removed, and the three assertions that used it compare the character list.+  - Previews cover the view inside a field card and free-standing, a long cast, an open card with a multi-line note, a name wider than the row, and accessibility5.+  - The comments that describe a text row are rewritten at the sites the smolspec lists.+  - Done when make test-core and make test-quick pass without SKIP_MAC and the previews render.+  - Blocked-by: yxy3fhq (The wrapping chip layout is public in ConstellationKit and keeps an oversized item inside its row), yxy3fhp (Chip open and close rules and every accessibility string are tested core values)++- [ ] 5. The change passes the full bar and is in the changelog <!-- id:yxy3fhs -->+  - Spec: smolspec.md Bar bullet.+  - CHANGELOG.md gains an Unreleased entry for T-2317.+  - make test-core, make test-quick without SKIP_MAC, make test-ui, make test-ui-ipad and make verify-identity all pass, and a forced recompile of the changed files shows no new compiler warnings.+  - All of these are host or simulator runs; nothing here installs or launches a Personal build.+  - Blocked-by: yxy3fhr (Both capture sheets draw the cast as chips with one inline detail card)++- [x] 6. A collapsed cast is capped at two chip rows with an expander chip+  - Spec: smolspec.md row-limit and measuring bullets; decision log Decision 1.+  - FlowLayout gains rowCount and visibleCount; ShareCharacterChips gains the expander strings and openAfterCollapse.+  - Tests first in ConstellationKitTests and ShareWorkContextTests: a cast that fits; one chip over the line; the expander costing a chip its place; an oversized chip and an oversized expander; zero and one character.+  - The view draws only the chips that fit and measures the rest with a hidden accessibility-hidden non-hit-testable copy in the background of the row.+  - Done when make test-core and make test-quick pass without SKIP_MAC and a forced recompile shows no new warnings.

Things to double-check

First frame on a device.

The row draws nothing until measurements land. SwiftUI should fold that into one update; the owner saw nothing wrong on the phone, but it is not asserted anywhere.

Short-name chips after Q14.

The minimum width was added after the owner's device check. A two- or three-letter name now reserves 44 pt, which shows as extra space to its right. Worth one more look on the phone.

An app Label wider than its whole row.

Now clamped where it used to overflow (WorkDetailView's meta line at accessibility sizes). If a Label resolves to icon-only when given a width, that case looks different rather than merely truncated.

Four UI tests red on main too.

WorksListOptionsUITests (2), WorksCreatorOptionsUITests (1) and WideLayoutUITests.testTheWorkNameInTheDetailColumnOpensTheWorkOnTheWorksPane. Reproduced on main at 397e0247 with the iPhone 18 Pro simulator; #77 and #78 did not touch them.