From eb5a098ff190beaf606e1d5e30a209415ef514c4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 4 Oct 2026 12:02:50 -0400 Subject: [PATCH 1/2] feat(chat): one-row glass composer with $ beside it The composer is a single row: +, the message field and send, in glass tinted 3% white with a lit rim and 28pt corners. A round $ sits left of the field and splits from the field's glass on the draft's first character, rejoining it when the draft is cleared. Send pops in on a draft and is gone without one. An empty field takes focus from a tap anywhere right of +. Text that wraps or holds a newline stacks the field: the text takes the full width and + and send drop to a row below. It stays stacked until the draft is cleared, so editing near the wrap point does not flip the layout. The caret and text spring into place together rather than the caret trailing on UIKit's own animation. The bar now reports its height with `onGeometryChange`. The `GeometryReader` report delivered only the height an animated stack started from, so the bar's clip stayed one row tall and cut off the top line. When the composer shrinks, the clip eases down on the stack spring while the transcript moves in one frame, as #908 does. The + menu holds camera and photos. It opens from the $'s margin at 220pt, its rows sharpening out of a blur as it grows from +, and springs back into + without a colour jump. The composer stays visible under the camera and photo cards, and a capture or pick collapses into its chip with the photo drawn throughout. --- .claude/plans/2026-10-04-two-row-composer.md | 20 + .../Screens/Conversation/AttachCard.swift | 6 +- .../Screens/Conversation/AttachMenu.swift | 12 +- .../Conversation/AttachOverKeyboard.swift | 16 +- .../Screens/Conversation/AttachPanel.swift | 6 +- .../Screens/Conversation/AttachSurface.swift | 91 +++- .../Conversation/ChatCameraSheet.swift | 4 +- .../Screens/Conversation/ChatPhotosCard.swift | 4 +- .../ChatScreenRepresentable.swift | 20 +- .../Conversation/ConversationBottomBar.swift | 465 ++++++++++++++---- .../Chat/AttachOverlayModeTests.swift | 8 +- .../Chat/AttachPanelPlacementTests.swift | 29 +- FlipcashTests/Chat/AttachPanelTests.swift | 35 +- .../Chat/ChatScreenBarClipTests.swift | 27 + .../Chat/ComposerStackingTests.swift | 44 ++ .../ConversationBarLeadingControlTests.swift | 89 ++-- .../FlipcashUI/Chat/BarAccessories.swift | 2 +- .../Chat/ChatScreenViewController.swift | 31 ++ .../Views/Containers/GlassBackground.swift | 42 ++ 19 files changed, 773 insertions(+), 178 deletions(-) create mode 100644 .claude/plans/2026-10-04-two-row-composer.md create mode 100644 FlipcashTests/Chat/ComposerStackingTests.swift diff --git a/.claude/plans/2026-10-04-two-row-composer.md b/.claude/plans/2026-10-04-two-row-composer.md new file mode 100644 index 000000000..0163c95c1 --- /dev/null +++ b/.claude/plans/2026-10-04-two-row-composer.md @@ -0,0 +1,20 @@ +# Two-row composer (E2) + +The composer field becomes two rows inside one glass field. + +- Top row: chip strip, then the message text, full width. +- Bottom row: 34pt `+` circle, grey "Send $" capsule, Spacer, white circular send button. +- Field padding 8 all round; the text row is inset 6 more on the leading side. + +## Decisions + +- `ConversationBarLeadingControl` keeps only what stands outside the field: `.cancelEdit`, `.sendCash` (pre-chat CTA), `.none`. +- New `ConversationBarBottomRow` (plus items, pill flag). Empty while editing and before the chat exists. Cash never appears in its menu: the pill replaces it. +- No bottom row (no `+`, no pill, or editing): single-row field with send on the text row. +- `AttachMenu` is restyled to the 34pt circle and rendered by `ConversationComposer`; panel toggle, frame reporting, stand-in and accessibility are unchanged. +- The attach surface stays an overlay on the bar's row; its collapsed radius is `plus.height / 2`. +- `BarMetrics.contentHeight` still means the single-row field. `BarMetrics.twoRowContentHeight` (84) is the two-row field. + +## Tests + +Bottom-row decision, Cash dropped from the menu, collapsed radius, bar height, and `AttachPanelPlacementTests` retargeted to `+`'s new column. diff --git a/Flipcash/Core/Screens/Conversation/AttachCard.swift b/Flipcash/Core/Screens/Conversation/AttachCard.swift index 3ccfec088..4e32be06b 100644 --- a/Flipcash/Core/Screens/Conversation/AttachCard.swift +++ b/Flipcash/Core/Screens/Conversation/AttachCard.swift @@ -55,6 +55,8 @@ final class AttachCard { /// That chip's frame in window coordinates, once the bar has laid it out. private(set) var landingChipFrame: CGRect? + /// The photo the card is shrinking into the chip, drawn by the surface for the whole landing. + private(set) var landingImage: UIImage? /// Whether the keyboard coming up takes the card down. Off while the card is drawn over the /// keyboard, where the keyboard never left, and an input view swap announces it again. @@ -105,9 +107,10 @@ final class AttachCard { /// Marks `chipID` as the chip the card will shrink onto, keeping the card up until the bar has /// laid that chip out. - func beginLanding(on chipID: ComposerChip.ID) { + func beginLanding(on chipID: ComposerChip.ID, image: UIImage? = nil) { landingChipID = chipID landingChipFrame = nil + landingImage = image } /// Records where the landing chip was laid out. Returns whether the card is still up and can now @@ -127,6 +130,7 @@ final class AttachCard { withTransaction(transaction) { landingChipID = nil landingChipFrame = nil + landingImage = nil } } diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift index 2afa5bfe1..a39811645 100644 --- a/Flipcash/Core/Screens/Conversation/AttachMenu.swift +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -35,8 +35,7 @@ enum AttachMenuItem: Equatable { } } -/// The composer's leading `+` control, in the slot Send Cash takes before the chat exists. Opens the -/// attach surface — the floating glass menu of Cash, Camera, and Photos, which grows out of `+` over +/// The `+` circle in the composer field's bottom row. Opens the attach surface — the floating glass menu of Cash, Camera, and Photos, which grows out of `+` over /// the field and collapses back into it — and reports where it is laid out, for the surface to grow /// from. The surface itself is ``AttachSurface``, drawn by the bar or over the keyboard. struct AttachMenu: View { @@ -61,14 +60,13 @@ struct AttachMenu: View { panel.animate { $0.toggle() } } label: { Image(systemName: SystemSymbol.plus.rawValue) - .font(.default(size: 20, weight: .semibold)) + .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.textMain) - .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) - .contentShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + .background(AttachSurfaceLayout.plusFill, in: Circle()) + .contentShape(Circle()) } .buttonStyle(.plain) - .glassBackground(cornerRadius: BarMetrics.cornerRadius) - .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) .accessibilityLabel("Attach") .accessibilityValue(panel.isOpen ? "Expanded" : "Collapsed") .accessibilityIdentifier("attach-menu-button") diff --git a/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift index 3598fa809..2acf7fd9d 100644 --- a/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift +++ b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift @@ -80,6 +80,10 @@ final class AttachOverKeyboard { /// `+`'s frame in window coordinates, as last laid out. var plusFrame: CGRect = .zero + /// How far left of the composer field the bar's row starts: `$`'s width and gap while it shows, + /// else zero. The menu opens out to there, so it covers the row it opens from. + var menuLeadingReach: CGFloat = 0 + /// Whether the photo card opens the full library as it next mounts in the bar. Set when All /// Photos hands the card over to the keyboard-down flow, whose sheet the keyboard would cover. var opensLibraryInBar = false @@ -101,13 +105,21 @@ nonisolated enum AttachOverlayLayout { /// The margin between a card and the screen's sides and bottom: the bar's keyboard-up edge inset. static let cardInset: CGFloat = 12 - /// The panel's frame for a panel `size` big: its leading edge on `+`'s, and centred on `+`'s + /// The panel's frame for a panel `size` big: its leading edge on the composer field's, which is + /// the field's padding out from `+`'s, and centred on `+`'s /// bottom, the composer's bottom edge. It straddles that edge, as ChatGPT's does, so its lower /// half lies over the keys that blur through it and its upper half over the field. static func panelFrame(plusFrame: CGRect, size: CGSize) -> CGRect { - CGRect(x: plusFrame.minX, y: plusFrame.maxY - size.height / 2, width: size.width, height: size.height) + // Centred on the field's bottom edge, a short menu tops out just under the field's top and + // leaves its corner showing; lift it to clear the top by `fieldTopOverhang`. + let fieldTop = plusFrame.maxY + BarMetrics.fieldPadding - BarMetrics.contentHeight + let y = min(plusFrame.maxY - size.height / 2, fieldTop - fieldTopOverhang) + return CGRect(x: plusFrame.minX - BarMetrics.fieldPadding, y: y, width: size.width, height: size.height) } + /// How far the open menu reaches above the composer field's top edge. + static let fieldTopOverhang: CGFloat = 8 + /// The point in a panel `size` big that `+`'s centre sits on, for the panel to grow out of and /// collapse back into. static func panelAnchor(plusFrame: CGRect, size: CGSize) -> UnitPoint { diff --git a/Flipcash/Core/Screens/Conversation/AttachPanel.swift b/Flipcash/Core/Screens/Conversation/AttachPanel.swift index 369aca1bc..9fd0c4281 100644 --- a/Flipcash/Core/Screens/Conversation/AttachPanel.swift +++ b/Flipcash/Core/Screens/Conversation/AttachPanel.swift @@ -48,9 +48,11 @@ final class AttachPanel { } /// Runs `change` on the panel's spring, and releases the room above the bar once the motion it - /// started has logically finished. + /// started has finished: logically for an open, fully settled for a close, so the surface is not + /// taken down before it has reached `+`. func animate(_ change: (AttachPanel) -> Void) { - withAnimation(ChatMotion.attachPanel.animation, completionCriteria: .logicallyComplete) { + let closes = isOpen + withAnimation(ChatMotion.attachPanel.animation, completionCriteria: closes ? .removed : .logicallyComplete) { change(self) } completion: { [weak self] in self?.exitDidFinish() diff --git a/Flipcash/Core/Screens/Conversation/AttachSurface.swift b/Flipcash/Core/Screens/Conversation/AttachSurface.swift index 9862f055f..06f1580e3 100644 --- a/Flipcash/Core/Screens/Conversation/AttachSurface.swift +++ b/Flipcash/Core/Screens/Conversation/AttachSurface.swift @@ -67,28 +67,41 @@ nonisolated struct AttachSurfaceShape: Equatable { /// Where the attach surface stands in each phase, in its host's coordinates. enum AttachSurfaceLayout { - /// `+`'s radius, which the surface grows out of and collapses back into. - static let plusCornerRadius: CGFloat = BarMetrics.cornerRadius - static let menuCornerRadius: CGFloat = 40 + /// Matches the composer field's corner, so the menu reads as part of it. + static let menuCornerRadius: CGFloat = BarMetrics.fieldCornerRadius + /// `+`'s flat fill on the composer field, which the surface turns into as it reaches `+`. + static let plusFill = Color.white.opacity(0.10) + /// How far past `+`'s size the surface has fully turned from `+`'s fill into the panel's glass. + static let plusBlendDistance: CGFloat = 40 static let cardCornerRadius: CGFloat = 24 /// A staged chip's radius, which a landing ends on. static let chipCornerRadius: CGFloat = 10 - /// The menu's width: enough to cover the field from `+`, short of the send button on a 375pt screen. - static let menuWidth: CGFloat = 280 + /// The menu's width: room for an icon and a short label, well short of the send button. + static let menuWidth: CGFloat = 220 /// A menu row's height, sized to the system's own attachment menus. static let menuRowHeight: CGFloat = 62 static let menuVerticalPadding: CGFloat = 10 + /// The rows' blur and scale while hidden, which they sharpen and grow out of as the menu opens. + static let rowsBloomBlur: CGFloat = 8 + static let rowsBloomScale: CGFloat = 0.85 /// The menu's size before its rows are measured. static func estimatedMenuSize(rowCount: Int) -> CGSize { CGSize(width: menuWidth, height: CGFloat(rowCount) * menuRowHeight + menuVerticalPadding * 2) } - /// The menu's frame for a menu `size` big, placed against `plus` by `placement`. - static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect { + /// The menu's frame for a menu `size` big, placed against `plus` by `placement`. Either way its + /// leading edge is the composer field's, ``BarMetrics/fieldPadding`` out from `+`'s, moved out a + /// further `leadingReach` at the same width; standing on `+`, its bottom edge is the field's too. + static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement, leadingReach: CGFloat = 0) -> CGRect { + let rect = fieldAlignedMenuRect(plus: plus, size: size, placement: placement) + return CGRect(x: rect.minX - leadingReach, y: rect.minY, width: rect.width, height: rect.height) + } + + private static func fieldAlignedMenuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect { switch placement { case .standsOnPlus: - CGRect(x: plus.minX, y: plus.maxY - size.height, width: size.width, height: size.height) + CGRect(x: plus.minX - BarMetrics.fieldPadding, y: plus.maxY + BarMetrics.fieldPadding - size.height, width: size.width, height: size.height) case .straddlesPlus: AttachOverlayLayout.panelFrame(plusFrame: plus, size: size) } @@ -100,11 +113,16 @@ enum AttachSurfaceLayout { CGRect(x: -outset, y: row.height - height, width: row.width + outset * 2, height: height) } + /// Returns the radius of the surface collapsed onto `plus`, which is a circle: half its height. + static func collapsedCornerRadius(plus: CGRect) -> CGFloat { + plus.height / 2 + } + /// The surface's shape in `phase`. A landing whose chip hasn't been laid out yet holds the card's. static func shape(for phase: AttachSurfacePhase, plus: CGRect, menu: CGRect, card: CGRect, landing: CGRect?) -> AttachSurfaceShape { switch phase { case .collapsed: - AttachSurfaceShape(rect: plus, cornerRadius: plusCornerRadius) + AttachSurfaceShape(rect: plus, cornerRadius: collapsedCornerRadius(plus: plus)) case .menu: AttachSurfaceShape(rect: menu, cornerRadius: menuCornerRadius) case .card: @@ -162,13 +180,14 @@ struct AttachSurface: View { let menu = AttachSurfaceLayout.menuRect( plus: plus, size: menuSize ?? AttachSurfaceLayout.estimatedMenuSize(rowCount: items.count), - placement: menuPlacement + placement: menuPlacement, + leadingReach: model.overKeyboard.menuLeadingReach ) let shape = AttachSurfaceLayout.shape(for: appeared ? phase : .collapsed, plus: plus, menu: menu, card: card, landing: landing) // Morphing, the surface is `+` at either end and stays opaque; a cross-fade fades it instead. let isVisible = motion.animatesGeometry || (appeared && phase != .collapsed) let showsPlusGlyph = !appeared || phase == .collapsed - AttachSurfaceFrame(shape: shape) { rect in + AttachSurfaceFrame(shape: shape, plus: plus, blendsIntoPlus: phase != .landing) { rect in ZStack(alignment: .topLeading) { plusGlyph .offset(x: plus.minX - rect.minX, y: plus.minY - rect.minY) @@ -176,7 +195,19 @@ struct AttachSurface: View { cardLayer(phase: phase) .frame(width: card.width, height: card.height) .offset(x: card.minX - rect.minX, y: card.minY - rect.minY) - rows(phase: phase) + // From the hand-off on, filling the surface at every step: the viewfinder holds the + // capture, and it collapses into the chip rather than an empty surface flying there. + if let image = model.attachCard.landingImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: rect.width, height: rect.height) + .clipped() + .allowsHitTesting(false) + .accessibilityHidden(true) + .transition(.identity) + } + rows(phase: phase, menu: menu, motion: motion) .frame(width: menu.width, height: menu.height, alignment: .topLeading) .offset(x: menu.minX - rect.minX, y: menu.minY - rect.minY) } @@ -207,21 +238,33 @@ struct AttachSurface: View { /// `+`'s glyph where `+` stands, so the surface reads as `+` itself as it grows and collapses. private var plusGlyph: some View { Image(systemName: SystemSymbol.plus.rawValue) - .font(.default(size: 20, weight: .semibold)) + .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.textMain) .frame(width: plus.width, height: plus.height) .accessibilityHidden(true) } - private func rows(phase: AttachSurfacePhase) -> some View { + /// The rows sharpen out of a blur and grow from `+` as the surface opens, and go back into it as + /// it closes, so they read as poured out of `+` rather than faded over it. + private func rows(phase: AttachSurfacePhase, menu: CGRect, motion: AttachMotion) -> some View { let panel = model.attachPanel + let shown = phase.showsRows + let blooms = motion.animatesGeometry + let anchor = UnitPoint( + x: menu.width > 0 ? (plus.midX - menu.minX) / menu.width : 0, + y: menu.height > 0 ? (plus.midY - menu.minY) / menu.height : 1 + ) return AttachMenuRows(items: items, onSelect: { item in panel.select(item, warmUp: model.attachWarmUp, onCash: actions.onCash, onCamera: actions.onCamera, onPhotos: actions.onPhotos) }) { panel.animate { $0.dismiss() } } .onGeometryChange(for: CGSize.self, of: { $0.size }) { menuSize = $0 } - .attachLayer(isShown: phase.showsRows) + .animation(ChatMotion.attachPanel.animation) { + $0.blur(radius: shown || !blooms ? 0 : AttachSurfaceLayout.rowsBloomBlur) + .scaleEffect(shown || !blooms ? 1 : AttachSurfaceLayout.rowsBloomScale, anchor: anchor) + } + .attachLayer(isShown: shown) } /// The camera and the photo picker, each mounted from the surface's first frame at the card's @@ -271,6 +314,10 @@ private extension View { private struct AttachSurfaceFrame: View, Animatable { var shape: AttachSurfaceShape + /// `+`'s frame, which the surface takes on the look of as it shrinks to `+`'s size. + let plus: CGRect + /// Whether the surface turns into `+`'s fill near `+`'s size; not while it lands on a chip. + let blendsIntoPlus: Bool let content: (CGRect) -> Content var animatableData: AnimatablePair { @@ -284,10 +331,16 @@ private struct AttachSurfaceFrame: View, Animatable { var body: some View { let rect = shape.rect let clip = RoundedRectangle(cornerRadius: shape.cornerRadius, style: .continuous) + let glass = glassAmount(at: rect) ZStack(alignment: .topLeading) { + clip + .fill(AttachSurfaceLayout.plusFill) + .frame(width: rect.width, height: rect.height) + .opacity(1 - glass) Color.clear .frame(width: rect.width, height: rect.height) .panelGlassBackground(cornerRadius: shape.cornerRadius) + .opacity(glass) content(rect) } .frame(width: rect.width, height: rect.height, alignment: .topLeading) @@ -295,4 +348,12 @@ private struct AttachSurfaceFrame: View, Animatable { .contentShape(clip) .offset(x: rect.minX, y: rect.minY) } + + /// How much of the panel's glass shows at `rect`, against `+`'s flat fill: none at `+`'s size, + /// all of it once the surface is a few dozen points bigger, so the two meet without a colour jump. + private func glassAmount(at rect: CGRect) -> CGFloat { + guard blendsIntoPlus else { return 1 } + let growth = max(rect.width - plus.width, rect.height - plus.height) + return min(max(growth / AttachSurfaceLayout.plusBlendDistance, 0), 1) + } } diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift index 95a4e1124..617515449 100644 --- a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift @@ -241,7 +241,7 @@ struct ChatCameraSheet: View { } } -/// A round control over the viewfinder: a white glyph on a dimmed disc. +/// A round control over the viewfinder: a white glyph on dark-tinted glass. private struct ChatCameraControlLabel: View { let systemImage: String @@ -251,7 +251,7 @@ private struct ChatCameraControlLabel: View { .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.white) .frame(width: 44, height: 44) - .background(Circle().fill(Color.black.opacity(0.35))) + .overlayGlassBackground(in: Circle()) .contentShape(Circle()) } } diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift index 3f14e2245..7b79c123c 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift @@ -131,7 +131,7 @@ struct ChatPhotosCard: View { .contentShape(Circle()) } .buttonStyle(.plain) - .glassBackground(cornerRadius: 22) + .overlayGlassBackground(in: Circle()) .clipShape(Circle()) .accessibilityLabel("Back to attach menu") .accessibilityIdentifier("attach-photos-back") @@ -155,7 +155,7 @@ struct ChatPhotosCard: View { .contentShape(Capsule()) } .buttonStyle(.plain) - .glassBackground(cornerRadius: 22) + .overlayGlassBackground(in: Capsule()) .clipShape(Capsule()) .accessibilityIdentifier("attach-photos-all") .transition(.opacity) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 30ee0d332..d040c30c8 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -429,7 +429,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen?.focusComposer() return } - card.beginLanding(on: chipID) + let chip = composer.chips.first { $0.id == chipID } + card.beginLanding(on: chipID, image: chip.map { $0.preview ?? $0.image }) screen?.focusComposer() // A chip the bar never lays out leaves nothing to land on, so the card goes anyway. DispatchQueue.main.asyncAfter(deadline: .now() + ChatMotion.attachCard.duration) { @@ -473,13 +474,14 @@ private struct MeasuredBarHeight: ViewModifier { content .fixedSize(horizontal: false, vertical: true) .overlayPreferenceValue(BarAccessoriesKey.self) { accessories in - GeometryReader { proxy in - Color.clear.onChange( - of: BarReport(height: proxy.size.height, accessories: accessories), - initial: true - ) { _, new in - report(new.height, new.accessories) - } + // `onGeometryChange`, not a `GeometryReader`: under an animated layout change the + // reader reports the height the animation starts from and then goes quiet, which + // leaves the clip short of the content it settles at. + Color.clear.onGeometryChange( + for: BarReport.self, + of: { BarReport(height: $0.size.height, accessories: accessories) } + ) { new in + report(new.height, new.accessories) } } // Sit on the host's bottom edge rather than in the middle of it. The two heights are @@ -493,7 +495,7 @@ private struct MeasuredBarHeight: ViewModifier { } /// One measurement of the bar: its height and the cards that make it up. -private struct BarReport: Equatable { +private nonisolated struct BarReport: Equatable, Sendable { let height: CGFloat let accessories: BarAccessories } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 6a75e2bcb..88e09ed97 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -155,12 +155,19 @@ private let replySpring = ChatMotion.replySurface.animation /// heights and corners can't desync. Deliberately not `Metrics.buttonHeight`/`buttonRadius` — beside /// the field the controls are field-sized, not standard-button-sized. enum BarMetrics { - static let fieldMinHeight: CGFloat = 34 - static let fieldVerticalPadding: CGFloat = 8 + nonisolated static let fieldMinHeight: CGFloat = 34 + nonisolated static let fieldVerticalPadding: CGFloat = 8 + /// The field's padding on every side: where `+` stands in from the field's leading edge, and so + /// how far the open attach menu reaches back past `+` to line up with the field. + nonisolated static let fieldPadding: CGFloat = 8 static let cornerRadius: CGFloat = 14 + /// The composer field's corner, rounder than the bar's other controls. + nonisolated static let fieldCornerRadius: CGFloat = 28 /// The height of every bar control: a single-line field plus its padding, and the height the /// Send Cash button morphs at while there is a composer beside it. - static let contentHeight: CGFloat = fieldMinHeight + fieldVerticalPadding * 2 + nonisolated static let contentHeight: CGFloat = fieldMinHeight + fieldVerticalPadding * 2 + /// The diameter of the controls beside the text: `+`, the Send Cash button and the send button. + nonisolated static let accessorySize: CGFloat = 34 /// The bar's own margin around its controls, above and below. static let contentPadding: CGFloat = 8 /// The margin between the bar's controls and the screen's sides while the keyboard is up. @@ -171,35 +178,57 @@ enum BarMetrics { static let compactInset: CGFloat = 32 } -/// What stands in the bar's leading slot, beside the message field. +/// What stands outside the message field, beside it. enum ConversationBarLeadingControl: Equatable { /// The way out of an edit. case cancelEdit /// The full-width Send Cash call to action, alone in the bar before the chat exists. case sendCash - /// The `+` menu with these rows. - case attach([AttachMenuItem]) - /// Nothing: the chat takes neither cash nor media from this user. + /// The round `$` beside the field once the chat exists, shown while the draft is empty. + case cash + /// Nothing beside the field. case none - /// Returns the control for the bar's state. An edit wins; before the chat exists the only - /// control is the Send Cash call to action; after, it is the attach menu, when it has a row. - init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool, acceptsMedia: Bool, attachedCount: Int) { + /// Returns the control for the bar's state. An edit wins; otherwise Send Cash, as the call to + /// action before the chat exists and as the round `$` after. + init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool) { if isEditing { self = .cancelEdit - } else if !chatExists { - self = showsSendCash ? .sendCash : .none + } else if !showsSendCash { + self = .none } else { - let items = AttachMenuItem.items( - showsCash: showsSendCash, - acceptsMedia: acceptsMedia, - attachedCount: attachedCount - ) - self = items.isEmpty ? .none : .attach(items) + self = chatExists ? .cash : .sendCash } } } +/// What the field's row holds beside the send button: the `+` menu. +struct ConversationBarBottomRow: Equatable { + /// The rows of the `+` menu; empty hides `+`. Never holds Cash, which the `$` beside the field + /// takes over. + let plusItems: [AttachMenuItem] + + /// A row with nothing in it. + static let empty = ConversationBarBottomRow(plusItems: []) + + /// Whether there is nothing for `+` to open. + var isEmpty: Bool { plusItems.isEmpty } + + /// Returns the row for the bar's state: empty during an edit and before the chat exists; + /// otherwise `+` while it has a row to open. + init(isEditing: Bool, chatExists: Bool, acceptsMedia: Bool, attachedCount: Int) { + guard !isEditing, chatExists else { + self.init(plusItems: []) + return + } + self.init(plusItems: AttachMenuItem.items(showsCash: false, acceptsMedia: acceptsMedia, attachedCount: attachedCount)) + } + + private init(plusItems: [AttachMenuItem]) { + self.plusItems = plusItems + } +} + /// The unified bottom bar: the attach menu beside the message field. /// A standard-size filled Send Cash alone until the chat exists server-side. struct ConversationBottomBar: View { @@ -241,6 +270,20 @@ struct ConversationBottomBar: View { /// The composer row's measured height, reported as part of what the mention list's room is /// measured without. @State private var composerRowHeight: CGFloat = 0 + /// Whether the round `$` shows: while the draft is empty. Set in a transaction after the text + /// update, so the split animates without carrying the field's text change with it. + @State private var cashIsShown = true + + /// How far left of the field the row starts, for the attach menu to open out to. + private var menuLeadingReach: CGFloat { + switch leadingControl { + case .cash: + cashIsShown ? BarMetrics.contentHeight + Self.leadingSpacing : 0 + case .cancelEdit, .sendCash, .none: + 0 + } + } + @Namespace private var composerGlassNamespace /// Whether the chat takes photos; false for an E2EE DM, whose encryption does not cover media. var acceptsMedia: Bool = false /// Fired as `+` opens the attach panel with the given rows: the panel goes up over the keyboard, @@ -264,6 +307,11 @@ struct ConversationBottomBar: View { /// The curve the bar narrows and widens on as the keyboard goes and comes. private static let widthSpring = Animation.spring(duration: 0.22, bounce: 0.14) + /// `$` splitting from the field's glass and joining back into it. + private static let cashSpring = Animation.spring(duration: 0.4, bounce: 0.3) + /// The gap between the leading control and the field. The composer's glass joins across no + /// more than this, so `$` stays bridged to the field while it travels and pinches off at rest. + static let leadingSpacing: CGFloat = 10 /// Whether the bar sits inset from the screen's sides: at rest with the keyboard down. It widens /// to the full edge inset as the keyboard comes up. Only once there is a composer; the pre-chat @@ -299,6 +347,37 @@ struct ConversationBottomBar: View { } } + /// The glass behind the leading control and the field, drawn as one layer apart from them so + /// `$` splits from the field and joins back into it. The real controls sit above it, outside the + /// container: in one, the glass composites above sibling content and covers the typed text. + @ViewBuilder + private var composerGlass: some View { + let field = RoundedRectangle(cornerRadius: BarMetrics.fieldCornerRadius, style: .continuous) + let layout = HStack(alignment: .bottom, spacing: Self.leadingSpacing) { + switch leadingControl { + case .cancelEdit: + // The button's size, so the field's glass starts where the field does. + Color.clear.frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + case .cash: + if cashIsShown { + Color.clear + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + .composerGlass(in: Circle(), id: "cash", namespace: composerGlassNamespace) + } + case .sendCash, .none: + EmptyView() + } + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .composerGlass(in: field, id: "field", namespace: composerGlassNamespace) + } + if #available(iOS 26, *) { + GlassEffectContainer(spacing: Self.leadingSpacing) { layout } + } else { + layout + } + } + private var composerBar: some View { // Bottom-aligned, against the bar's own pinned bottom: the field is the side that grows, and // top-aligning the control beside it made the control travel with every line the draft @@ -307,13 +386,12 @@ struct ConversationBottomBar: View { // sprang underneath it. let motion = AttachMotion(reduceMotion: reduceMotion) let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { - HStack(alignment: .bottom, spacing: 10) { + HStack(alignment: .bottom, spacing: Self.leadingSpacing) { // An edit takes over the bar: the leading control becomes the way out of it and the - // attach menu steps aside until it resolves, the way WhatsApp hides its accessory controls. + // field's bottom row steps aside until it resolves, the way WhatsApp hides its accessory controls. switch leadingControl { case .cancelEdit: CancelEditButton { composer.endEditing() } - .hiddenUnderAttachCard(showsCard) case .sendCash: SendCashMorphButton( symbol: symbol, @@ -330,38 +408,54 @@ struct ConversationBottomBar: View { expandedTitle: isTipDm ? startChattingTitle : nil, action: onSendCash ) - .hiddenUnderAttachCard(showsCard) - case .attach(let items): - AttachMenu( - items: items, - panel: model.attachPanel, - hidesButton: showsCard, + case .cash: + if cashIsShown { + ComposerCashButton(symbol: symbol, action: onSendCash) + .transition(.scale(scale: 0.4).combined(with: .opacity)) + } + case .none: + EmptyView() + } + if chatExists { + ConversationComposer( + conversationID: conversationID, + model: model, + composer: composer, + bottomRow: bottomRow, + hidesPlus: showsCard, // Swapped out in one frame for the surface, which is drawn as `+` where it stands. - isStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, - onOpen: onAttachOpen, - onPlusFrame: { model.overKeyboard.plusFrame = $0 } + plusIsStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, + onAttachOpen: onAttachOpen ) - // Over the field beside it, which its panel floats across. + // Over the row's other controls, which the attach panel floats across. .zIndex(1) - case .none: - EmptyView() + .transition(.opacity) } + } + .background { if chatExists { - ConversationComposer(conversationID: conversationID, model: model, composer: composer) - .hiddenUnderAttachCard(showsCard) - .transition(.opacity) + composerGlass } } + // A draft already there when the chat opens hides `$` without animating it out. + .onAppear { cashIsShown = composer.draft.isEmpty } + .onChange(of: composer.draft.isEmpty) { _, isEmpty in + guard cashIsShown != isEmpty else { return } + withAnimation(Self.cashSpring) { cashIsShown = isEmpty } + } + .onChange(of: menuLeadingReach, initial: true) { _, reach in + model.overKeyboard.menuLeadingReach = reach + } // Kept mounted under the card, so `+` is there for the surface to shrink back into and the - // field is there to take focus the moment the card closes. Each control fades on its own - // rather than the row, which holds the surface. An open panel lets a touch on the row — + // field is there to take focus the moment the card closes. Left visible, not faded: the + // card grows over it and shrinks back onto it, so it is never seen missing. An open panel lets a touch on the row — // send included — fall through to the dismiss area behind the bar, so it only closes the panel. .allowsHitTesting(!showsCard && !model.attachPanel.isOpen) .accessibilityHidden(showsCard) // After the row's own opacity, so the surface does not fade with it. .overlay(alignment: .topLeading) { - if model.attachSurfaceIsMounted, !model.overKeyboard.isActive, case .attach(let items) = leadingControl { - attachSurface(items: items) + if model.attachSurfaceIsMounted, !model.overKeyboard.isActive, !bottomRow.plusItems.isEmpty { + attachSurface(items: bottomRow.plusItems) } } } @@ -552,19 +646,21 @@ struct ConversationBottomBar: View { /// Whether the camera should be warm: while the open panel offers it, or its card shows it. private var wantsCameraWarm: Bool { let showsCamera = model.attachCard.content == .camera - switch leadingControl { - case .attach(let items): - return showsCamera || (model.attachPanel.isOpen && items.contains(.camera)) - case .cancelEdit, .sendCash, .none: - return showsCamera - } + return showsCamera || (model.attachPanel.isOpen && bottomRow.plusItems.contains(.camera)) } private var leadingControl: ConversationBarLeadingControl { ConversationBarLeadingControl( isEditing: composer.isEditing, chatExists: chatExists, - showsSendCash: showsSendCash, + showsSendCash: showsSendCash + ) + } + + private var bottomRow: ConversationBarBottomRow { + ConversationBarBottomRow( + isEditing: composer.isEditing, + chatExists: chatExists, acceptsMedia: acceptsMedia, attachedCount: composer.chips.count ) @@ -938,64 +1034,177 @@ struct ConversationComposer: View { let conversationID: ConversationID? @Bindable var model: ConversationBarModel @Bindable var composer: ComposerModel + /// What the row holds beside the text: `+`. + var bottomRow = ConversationBarBottomRow.empty + /// Whether `+` is hidden under the camera or photo card. + var hidesPlus = false + /// Whether the attach surface is drawn in `+`'s place, which hides `+`. + var plusIsStoodInFor = false + /// Fired as `+` opens the attach panel with the given rows. + var onAttachOpen: ([AttachMenuItem]) -> Void = { _ in } @Environment(ConversationController.self) private var conversationController @FocusState private var isFocused: Bool /// Send button scale-in/out as text appears/clears. private static let sendButtonSpring = ChatMotion.sendButton.animation + /// The text's and chips' inset from the field's leading edge. private static let leadingInset: CGFloat = 14 - private static let trailingInset: CGFloat = 8 + /// The stacked text's inset from the field's leading edge, matched to its inset from the top so + /// the first line sits evenly in the corner. + private static let stackedLeadingInset: CGFloat = 12 + /// The gap between the row's controls and the text. + private static let controlSpacing: CGFloat = 8 + /// The move between the one-row and stacked layouts. + /// The clip around the bar follows a shrink on `replySurface`, so the glass shares it. + private static let stackSpring = ChatMotion.replySurface.animation + + /// Whether the text spans the field with the controls in a row under it. + @State private var isStacked = false + /// The text's own layout, switched without animation: an animated width re-lays the text + /// every frame, and UIKit chases each new caret position on its own timing. + @State private var textIsStacked = false + /// The text's offset from its new place, sprung back to zero so it travels as one piece. + @State private var textShift: CGFloat = 0 + /// Whether send is up; follows `showsSubmit` in its own transaction so the pop animates. + @State private var submitIsShown = false + /// The text field's width while it sits inline between the controls. + /// The row's width, which stacking does not change, so the inline text width can be derived + /// in either layout rather than measured from a field that is about to move. + @State private var rowWidth: CGFloat = 0 + /// The draft's width laid out on one line. + @State private var draftLineWidth: CGFloat = 0 + + private var hasPlus: Bool { !bottomRow.plusItems.isEmpty } + + private var inlineLeadingPadding: CGFloat { + hasPlus ? BarMetrics.accessorySize + Self.controlSpacing : Self.leadingInset - BarMetrics.fieldPadding + } + + private func textLeadingPadding(stacked: Bool) -> CGFloat { + stacked ? Self.stackedLeadingInset - BarMetrics.fieldPadding : inlineLeadingPadding + } + + private var inlineTrailingPadding: CGFloat { BarMetrics.accessorySize + Self.controlSpacing } + + /// Stacks once the draft wraps or takes a newline, and unstacks only when it is cleared, so + /// deleting back under one line does not bounce the layout. + private func updateStacking() { + let stacked = Self.stacks( + draft: composer.draft, + wasStacked: textIsStacked, + draftLineWidth: draftLineWidth, + inlineWidth: rowWidth > 0 ? rowWidth - inlineLeadingPadding - inlineTrailingPadding : 0 + ) + // Its own transaction, after the text update has landed: the whole bar follows the field up + // or down, while the text update itself stays unanimated. + guard stacked != textIsStacked else { return } + let shift = textLeadingPadding(stacked: textIsStacked) - textLeadingPadding(stacked: stacked) + var snap = Transaction() + snap.disablesAnimations = true + withTransaction(snap) { + textIsStacked = stacked + textShift = shift + } + // A frame later, so the shift has rendered before it springs back with the bar. + Task { @MainActor in + withAnimation(Self.stackSpring) { + isStacked = stacked + textShift = 0 + } + } + } + + /// Whether the composer stacks for `draft`, given whether it was stacked and the draft's + /// one-line width against the inline field's. + nonisolated static func stacks(draft: String, wasStacked: Bool, draftLineWidth: CGFloat, inlineWidth: CGFloat) -> Bool { + guard !draft.isEmpty else { return false } + if wasStacked || draft.contains(where: \.isNewline) { return true } + return inlineWidth > 0 && draftLineWidth > inlineWidth + } @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let motion = AttachMotion(reduceMotion: reduceMotion) - let textRow = HStack(alignment: .bottom, spacing: 10) { - TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) - .font(.appTextMessage) - .foregroundStyle(Color.textMain) - .tint(.white) - .lineLimit(1...5) - .focused($isFocused) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(minHeight: BarMetrics.fieldMinHeight) - // Queried by the UI tests. The placeholder is not usable as a handle: it is gone the - // moment there is a draft, so a test that types and then reads the field back finds - // nothing. A multiline `TextField(axis:)` also surfaces as a text view wearing a - // text-field automation type, so the query has to be identifier-based, not type-based. - .accessibilityIdentifier("composer-message-field") - - // The spring is scoped to the button, not to the row. On the row it took the field - // into the transaction as well, and `showsSubmit` falls on the same update that empties - // the draft — so the field's text update ran as an animated one against its text view, - // where it can be coalesced away. That leaves the sent text on screen with the binding - // already empty, and an unchanged binding never pushes it again. - Group { - if showsSubmit { - Button(action: submit) { - Image(systemName: submitSymbol) - .font(.default(size: 16, weight: .bold)) - .foregroundStyle(Color.textAction) - .frame(width: 34, height: 34) - .background(Color.white, in: RoundedRectangle(cornerRadius: 6)) - // Arrow and checkmark are the same button in two jobs, so the glyph swaps in - // place rather than the button popping out and a new one popping back. - .contentTransition(.symbolEffect(.replace)) - } - .buttonStyle(.plain) - .accessibilityLabel(composer.isEditing ? "Save" : "Send") - .accessibilityIdentifier("send-message-button") - // Pop from 60% + fade, so the opacity ramp actually reads - // (scaling from 0 hides the fade behind a tiny speck). - .transition(.scale(scale: 0.6).combined(with: .opacity)) + let textField = TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + .tint(.white) + .lineLimit(1...5) + .focused($isFocused) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: BarMetrics.fieldMinHeight) + // Queried by the UI tests. The placeholder is not usable as a handle: it is gone the + // moment there is a draft, so a test that types and then reads the field back finds + // nothing. A multiline `TextField(axis:)` also surfaces as a text view wearing a + // text-field automation type, so the query has to be identifier-based, not type-based. + .accessibilityIdentifier("composer-message-field") + + // One row: `+`, the text, `$` until there is a draft, and send. Once the text wraps or takes + // a newline the row stacks: the text spans the field and the controls drop to a row under + // it, until the draft is cleared. The text field never moves in the hierarchy, only its + // insets do, so it keeps focus and the keyboard through the change. + let controls = HStack(alignment: .bottom, spacing: 0) { + if hasPlus { + AttachMenu( + items: bottomRow.plusItems, + panel: model.attachPanel, + hidesButton: hidesPlus, + isStoodInFor: plusIsStoodInFor, + onOpen: onAttachOpen, + onPlusFrame: { model.overKeyboard.plusFrame = $0 } + ) + } + Spacer(minLength: 0) + // The open menu covers this row; only `+` stays, for the menu to collapse back into. + sendButton + .animation(Self.sendButtonSpring) { + $0.opacity(model.attachPanel.isOpen ? 0 : 1) } + } + + let row = ZStack(alignment: .bottomLeading) { + textField + .background(alignment: .leading) { + // The draft's width on one line, to tell when the inline field would wrap. + Text(composer.draft.isEmpty ? " " : composer.draft) + .font(.appTextMessage) + .lineLimit(1) + .fixedSize() + .hidden() + .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in + draftLineWidth = width + updateStacking() + } + } + // The text snaps to its new layout and `textShift` carries it there on the bar's spring. + .padding(.leading, textLeadingPadding(stacked: textIsStacked)) + .padding(.trailing, textIsStacked ? 0 : inlineTrailingPadding) + .offset(x: textShift) + // Position only, so it can spring with the bar without re-laying the text. + .padding(.bottom, isStacked ? BarMetrics.accessorySize + BarMetrics.fieldVerticalPadding : 0) + controls + } + .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in + rowWidth = width + updateStacking() + } + .onChange(of: composer.draft) { updateStacking() } + // An empty field is small beside the space send will take; a tap anywhere right of `+`, + // out to the field's edges, focuses it. Behind the row, so `+` keeps its own taps. + .background { + if composer.draft.isEmpty { + Color.clear + .padding(.vertical, -BarMetrics.fieldPadding) + .padding(.trailing, -BarMetrics.fieldPadding) + .contentShape(Rectangle()) + .onTapGesture { isFocused = true } } - .animation(Self.sendButtonSpring, value: showsSubmit) } - // The staged photos ride inside the field, above the text, so the field reads as one message. - let field = VStack(alignment: .leading, spacing: BarMetrics.fieldVerticalPadding) { + // The staged photos ride inside the field, above the row, so the field reads as one message. + let content = VStack(alignment: .leading, spacing: BarMetrics.fieldVerticalPadding) { if ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing) { ComposerChipStrip( chips: composer.chips, @@ -1006,22 +1215,18 @@ struct ConversationComposer: View { edgeInset: Self.leadingInset ) // Out to the field's own edges, so chips scroll under a fade rather than a hard margin. - .padding(.leading, -Self.leadingInset) - .padding(.trailing, -Self.trailingInset) + .padding(.leading, -BarMetrics.fieldPadding) + .padding(.trailing, -BarMetrics.fieldPadding) .padding(.top, BarMetrics.fieldVerticalPadding / 2) .transition(motion.stripTransition(isLanding: model.attachCard.landingChipID != nil)) } - textRow + row } - return field - .padding(.leading, Self.leadingInset) - .padding(.trailing, Self.trailingInset) - .padding(.vertical, BarMetrics.fieldVerticalPadding) - // Glass *behind* the field, not wrapping it: wrapping an editable - // TextField in `glassEffect` reparents its text view into the glass - // platter and breaks the text-selection grabbers. - .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + return content + .padding(BarMetrics.fieldPadding) + // The field's glass is drawn by the bar, behind the whole row, so `$` can split from it. + .composerRim(in: RoundedRectangle(cornerRadius: BarMetrics.fieldCornerRadius, style: .continuous)) // Focus is the single source of `isComposing` — the button morph and the // screen's interactive-dismiss gate both key off it. Losing focus // (keyboard swiped down) ends composing. @@ -1037,6 +1242,45 @@ struct ConversationComposer: View { } } + /// The confirm button. The spring is scoped to it, not to the row it sits in. + private var sendButton: some View { + // The spring is scoped to the button, not to the row. On the row it took the field + // into the transaction as well, and `showsSubmit` falls on the same update that empties + // the draft — so the field's text update ran as an animated one against its text view, + // where it can be coalesced away. That leaves the sent text on screen with the binding + // already empty, and an unchanged binding never pushes it again. + // A ZStack, not a Group: a Group hands its modifiers to its children, so with the button + // gone the slot's frame and the callbacks below would attach to nothing. + ZStack { + if submitIsShown { + Button(action: submit) { + Image(systemName: submitSymbol) + .font(.default(size: 16, weight: .bold)) + .foregroundStyle(Color.textAction) + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + .background(Color.white, in: Circle()) + // Arrow and checkmark are the same button in two jobs, so the glyph swaps in + // place rather than the button popping out and a new one popping back. + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .accessibilityLabel(composer.isEditing ? "Save" : "Send") + .accessibilityIdentifier("send-message-button") + // Pop from 60% + fade, so the opacity ramp actually reads + // (scaling from 0 hides the fade behind a tiny speck). + .transition(.scale(scale: 0.6).combined(with: .opacity)) + } + } + // The slot stays, so the text does not reflow as the button comes and goes. + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + // Its own transaction after the text update: an implicit animation keyed on `showsSubmit` + // rode the text field's update, which carries no animation, so the pop never played. + .onAppear { submitIsShown = showsSubmit } + .onChange(of: showsSubmit) { _, shows in + withAnimation(Self.sendButtonSpring) { submitIsShown = shows } + } + } + /// The hint names what the field will send. An edit arrives with the existing text already in /// the field, so its hint is never on screen and stays the new-message one. private var fieldPrompt: String { @@ -1107,6 +1351,27 @@ struct ConversationComposer: View { /// The way out of an edit: the bar's leading control while the field holds an existing message, /// standing where Send Cash stands the rest of the time. Field-sized and glass, so the swap reads /// as the same control changing job rather than a foreign button arriving. +/// The round `$` beside the field. Its glass is drawn by the bar, joined to the field's. +private struct ComposerCashButton: View { + + let symbol: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(symbol) + .font(.appTextXL) + .foregroundStyle(Color.textMain) + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .composerRim(in: Circle()) + .accessibilityLabel("Send Cash") + .accessibilityIdentifier("send-cash-button") + } +} + private struct CancelEditButton: View { let onCancel: () -> Void diff --git a/FlipcashTests/Chat/AttachOverlayModeTests.swift b/FlipcashTests/Chat/AttachOverlayModeTests.swift index 61012b968..02359f029 100644 --- a/FlipcashTests/Chat/AttachOverlayModeTests.swift +++ b/FlipcashTests/Chat/AttachOverlayModeTests.swift @@ -81,13 +81,15 @@ struct AttachOverlayModeTests { #expect(AttachOverlayMode.select(keyboardHeight: minimum, hasKeyboardWindow: false, canReplaceInputView: true) == .inputView) } - @Test("The panel straddles the composer's bottom edge and grows out of +") + @Test("The panel straddles the composer's bottom edge, clears its top, and grows out of +") func panelStraddlesTheComposerEdge() { let plus = CGRect(x: 12, y: 500, width: 50, height: 50) let size = CGSize(width: 240, height: 144) let frame = AttachOverlayLayout.panelFrame(plusFrame: plus, size: size) - #expect(frame.minX == plus.minX) - #expect(frame.midY == plus.maxY) + #expect(frame.minX == plus.minX - BarMetrics.fieldPadding) + #expect(frame.minY < frame.maxY && frame.minY <= plus.maxY && frame.maxY >= plus.maxY) + let fieldTop = plus.maxY + BarMetrics.fieldPadding - BarMetrics.contentHeight + #expect(frame.minY <= fieldTop - AttachOverlayLayout.fieldTopOverhang) let anchor = AttachOverlayLayout.panelAnchor(plusFrame: plus, size: size) #expect(frame.minX + anchor.x * size.width == plus.midX) diff --git a/FlipcashTests/Chat/AttachPanelPlacementTests.swift b/FlipcashTests/Chat/AttachPanelPlacementTests.swift index ab69196ff..9a54eb6f4 100644 --- a/FlipcashTests/Chat/AttachPanelPlacementTests.swift +++ b/FlipcashTests/Chat/AttachPanelPlacementTests.swift @@ -76,9 +76,10 @@ struct AttachPanelHost { /// The bar's measured height, as last reported to the screen. var barHeight: CGFloat { screenBox.barHeight } - /// The window-space point `distance` above the bar's top edge, over `+`'s column. + /// The window-space point `distance` above the bar's top edge, over `+`'s column: `+` is the + /// first control in the field's bottom row, past the compact bar's margin and the field's padding. func pointAboveBar(by distance: CGFloat) -> CGPoint { - CGPoint(x: 40, y: window.bounds.height - barHeight - distance) + CGPoint(x: model.overKeyboard.plusFrame.midX, y: window.bounds.height - barHeight - distance) } /// The window's rendered colour at `point`. @@ -158,7 +159,7 @@ struct AttachPanelPlacementTests { // Inside the panel's rows, above the bar: before the fix the panel hung down from `+`'s top // and nothing of the bar reached here. let point = host.pointAboveBar(by: 40) - let transcript = host.renderedColor(at: CGPoint(x: 40, y: 100), backdrop: .black) + let transcript = host.renderedColor(at: CGPoint(x: point.x, y: 100), backdrop: .black) let probe = host.renderedColor(at: point, backdrop: .black) #expect(!probe.isClose(to: transcript), "Panel is not drawn above the bar") @@ -167,6 +168,28 @@ struct AttachPanelPlacementTests { #expect(hit.isDescendant(of: host.barHost.view), "Taps above the bar miss the panel") } + @Test("The bar is one row tall: +, the text, and the controls beside it") + func barHeight_isOneRow() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + let expected = BarMetrics.contentHeight + BarMetrics.contentPadding * 2 + #expect(abs(host.barHeight - expected) < 1, "Bar is \(host.barHeight) tall, expected \(expected)") + } + + @Test("The menu's leading edge is the composer field's, past the compact margin and the `$` beside it") + func menu_linesUpWithField() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + let plus = host.model.overKeyboard.plusFrame + let menu = AttachSurfaceLayout.menuRect(plus: plus, size: CGSize(width: 280, height: 200), placement: .standsOnPlus) + let field = BarMetrics.compactInset + BarMetrics.contentHeight + ConversationBottomBar.leadingSpacing + #expect(abs(menu.minX - field) < 1, "Menu starts at \(menu.minX), the field at \(field)") + } + @Test("The card stands on the composer over the transcript, takes taps on itself, and lets taps above it through") func card_overlaysTranscript() async throws { let host = try AttachPanelHost() diff --git a/FlipcashTests/Chat/AttachPanelTests.swift b/FlipcashTests/Chat/AttachPanelTests.swift index 72c8bf029..4634660f0 100644 --- a/FlipcashTests/Chat/AttachPanelTests.swift +++ b/FlipcashTests/Chat/AttachPanelTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation +import UIKit import FlipcashUI @testable import Flipcash @@ -170,6 +171,21 @@ struct AttachSurfaceTests { #expect(!model.attachSurfaceIsMounted) } + @Test("A landing holds the staged photo from the hand-off until it has landed") + func landingHoldsImage() { + let model = ConversationBarModel() + model.attachCard.open(.camera, screenHeight: 874) + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 3)).image { _ in } + + model.attachCard.beginLanding(on: UUID(), image: image) + #expect(model.attachCard.landingImage === image) + model.attachCard.close() + #expect(model.attachCard.landingImage === image, "The surface still draws it as it shrinks") + + model.attachCard.endLanding() + #expect(model.attachCard.landingImage == nil) + } + @Test("Only the menu shows rows, and only a card shows card content") func contentPerPhase() { #expect(AttachSurfacePhase.menu.showsRows) @@ -188,7 +204,8 @@ struct AttachSurfaceTests { let chip = CGRect(x: 20, y: 640, width: 56, height: 56) let collapsed = AttachSurfaceLayout.shape(for: .collapsed, plus: plus, menu: menu, card: card, landing: chip) - #expect(collapsed == AttachSurfaceShape(rect: plus, cornerRadius: AttachSurfaceLayout.plusCornerRadius)) + #expect(collapsed == AttachSurfaceShape(rect: plus, cornerRadius: plus.height / 2)) + #expect(AttachSurfaceLayout.collapsedCornerRadius(plus: plus) == 22) let open = AttachSurfaceLayout.shape(for: .menu, plus: plus, menu: menu, card: card, landing: nil) #expect(open == AttachSurfaceShape(rect: menu, cornerRadius: AttachSurfaceLayout.menuCornerRadius)) let carded = AttachSurfaceLayout.shape(for: .card(.camera), plus: plus, menu: menu, card: card, landing: nil) @@ -204,9 +221,23 @@ struct AttachSurfaceTests { let plus = CGRect(x: 16, y: 700, width: 44, height: 44) let size = CGSize(width: 240, height: 144) let standing = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .standsOnPlus) - #expect(standing == CGRect(x: 16, y: 600, width: 240, height: 144)) + #expect(standing == CGRect(x: 16 - BarMetrics.fieldPadding, y: 600 + BarMetrics.fieldPadding, width: 240, height: 144)) + #expect(standing.minX == plus.minX - BarMetrics.fieldPadding, "The menu lines up with the field's edge") + #expect(standing.maxY == plus.maxY + BarMetrics.fieldPadding, "The menu covers the field's bottom edge") let straddling = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .straddlesPlus) #expect(straddling == AttachOverlayLayout.panelFrame(plusFrame: plus, size: size)) + #expect(straddling.minX == plus.minX - BarMetrics.fieldPadding, "The over-keyboard menu lines up with the field's edge") + } + + @Test("With $ beside the field the menu moves out over it at the same width", arguments: [AttachMenuPlacement.standsOnPlus, .straddlesPlus]) + func menuReachesOverCash(placement: AttachMenuPlacement) { + let plus = CGRect(x: 60, y: 700, width: 34, height: 34) + let size = CGSize(width: 280, height: 144) + let aligned = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: placement) + let reaching = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: placement, leadingReach: 44) + #expect(reaching.minX == aligned.minX - 44) + #expect(reaching.width == aligned.width) + #expect(reaching.minY == aligned.minY && reaching.height == aligned.height) } @Test("The bar's card stands on the row's bottom edge, out to the keyboard-up margin") diff --git a/FlipcashTests/Chat/ChatScreenBarClipTests.swift b/FlipcashTests/Chat/ChatScreenBarClipTests.swift index b8379cf2a..9f6471cb9 100644 --- a/FlipcashTests/Chat/ChatScreenBarClipTests.swift +++ b/FlipcashTests/Chat/ChatScreenBarClipTests.swift @@ -106,6 +106,24 @@ struct ChatScreenBarClipTests { #expect(clipHeight(bar, window) == composerRow + secondLine) } + @Test("A composer shrinking holds the clip until its spring is done, and reserves the new room at once") + func composerShrink_holdsTheClip() async throws { + let (screen, bar, window) = makeScreen() + let stacked = composerRow + 48 + + report(screen, composerRow) + report(screen, stacked) + let transcript = try #require(screen.view.firstSubview(of: UICollectionView.self)) + let insetBefore = transcript.contentInset.bottom + + report(screen, composerRow) + #expect(transcript.contentInset.bottom == insetBefore - 48) + #expect(clipHeight(bar, window) == stacked, "The clip keeps the text and caret uncovered on the way down") + + try await Task.sleep(for: .seconds(ChatMotion.replySurface.duration + 0.2)) + #expect(clipHeight(bar, window) == composerRow) + } + @Test("The mention list stacks over an open reply and the clip reaches the whole bar") func mentionsOverReply_clipReachesTheWholeBar() { let (screen, bar, window) = makeScreen() @@ -184,3 +202,12 @@ struct ChatScreenBarClipTests { #expect(rooms.last.map { $0 - strip } == withStrip) } } + +private extension UIView { + func firstSubview(of type: T.Type) -> T? { + for subview in subviews { + if let match = subview as? T ?? subview.firstSubview(of: type) { return match } + } + return nil + } +} diff --git a/FlipcashTests/Chat/ComposerStackingTests.swift b/FlipcashTests/Chat/ComposerStackingTests.swift new file mode 100644 index 000000000..41fe2de16 --- /dev/null +++ b/FlipcashTests/Chat/ComposerStackingTests.swift @@ -0,0 +1,44 @@ +// +// ComposerStackingTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import Flipcash + +@Suite("Composer stacking") +struct ComposerStackingTests { + + @Test("A draft that fits on one line stays inline") + func fitsInline() { + #expect(!ConversationComposer.stacks(draft: "Hi", wasStacked: false, draftLineWidth: 20, inlineWidth: 200)) + } + + @Test("A draft wider than the inline field stacks") + func wrapStacks() { + #expect(ConversationComposer.stacks(draft: "Long", wasStacked: false, draftLineWidth: 201, inlineWidth: 200)) + } + + @Test("A newline stacks, however short the draft") + func newlineStacks() { + #expect(ConversationComposer.stacks(draft: "H\n", wasStacked: false, draftLineWidth: 10, inlineWidth: 200)) + } + + @Test("Deleting back under one line stays stacked") + func staysStacked() { + #expect(ConversationComposer.stacks(draft: "H", wasStacked: true, draftLineWidth: 10, inlineWidth: 200)) + } + + @Test("Clearing the draft unstacks") + func clearUnstacks() { + #expect(!ConversationComposer.stacks(draft: "", wasStacked: true, draftLineWidth: 0, inlineWidth: 200)) + } + + @Test("Before the field is measured, only a newline stacks") + func unmeasured() { + #expect(!ConversationComposer.stacks(draft: "Long", wasStacked: false, draftLineWidth: 500, inlineWidth: 0)) + } +} diff --git a/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift b/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift index a0f792d3f..c166c3969 100644 --- a/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift +++ b/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift @@ -15,51 +15,82 @@ struct ConversationBarLeadingControlTests { private func control( isEditing: Bool = false, chatExists: Bool = true, - showsSendCash: Bool = true, - acceptsMedia: Bool = true, - attachedCount: Int = 0 + showsSendCash: Bool = true ) -> ConversationBarLeadingControl { - ConversationBarLeadingControl( - isEditing: isEditing, - chatExists: chatExists, - showsSendCash: showsSendCash, - acceptsMedia: acceptsMedia, - attachedCount: attachedCount - ) + ConversationBarLeadingControl(isEditing: isEditing, chatExists: chatExists, showsSendCash: showsSendCash) } - @Test("Before the chat exists, Send Cash stays the full-width call to action", arguments: [true, false]) - func callToActionBeforeChatExists(acceptsMedia: Bool) { - #expect(control(chatExists: false, acceptsMedia: acceptsMedia) == .sendCash) + @Test("Before the chat exists, Send Cash stays the full-width call to action") + func callToActionBeforeChatExists() { + #expect(control(chatExists: false) == .sendCash) } - @Test("Before the chat exists with nothing to send, the slot is empty") + @Test("Before the chat exists with nothing to send, nothing stands outside the field") func emptyBeforeChatExistsWithoutCash() { #expect(control(chatExists: false, showsSendCash: false) == .none) } - @Test("Once the chat exists, the slot is the attach menu with every row") - func attachMenuOnceChatExists() { - #expect(control() == .attach([.cash, .camera, .photos])) + @Test("Once the chat exists, Send Cash is the round `$` beside the field") + func cashOnceChatExists() { + #expect(control() == .cash) } - @Test("An E2EE chat keeps the attach menu with Cash alone") - func attachMenuCashOnlyWithoutMedia() { - #expect(control(acceptsMedia: false) == .attach([.cash])) + @Test("Once the chat exists with nothing to send, nothing stands beside the field") + func noneOnceChatExistsWithoutCash() { + #expect(control(showsSendCash: false) == .none) } - @Test("A chat that takes neither cash nor media leaves the slot empty") - func emptyWithoutCashOrMedia() { - #expect(control(showsSendCash: false, acceptsMedia: false) == .none) + @Test("An edit takes the slot whatever else is true", arguments: [true, false]) + func cancelWhileEditing(chatExists: Bool) { + #expect(control(isEditing: true, chatExists: chatExists) == .cancelEdit) } +} + +@MainActor +@Suite("Conversation bar bottom row") +struct ConversationBarBottomRowTests { - @Test("A full composer in a chat without cash leaves the slot empty") - func emptyWhenFullWithoutCash() { - #expect(control(showsSendCash: false, attachedCount: ComposerModel.maxAttachments) == .none) + private func row( + isEditing: Bool = false, + chatExists: Bool = true, + acceptsMedia: Bool = true, + attachedCount: Int = 0 + ) -> ConversationBarBottomRow { + ConversationBarBottomRow( + isEditing: isEditing, + chatExists: chatExists, + acceptsMedia: acceptsMedia, + attachedCount: attachedCount + ) } - @Test("An edit takes the slot whatever else is true", arguments: [true, false]) - func cancelWhileEditing(chatExists: Bool) { - #expect(control(isEditing: true, chatExists: chatExists) == .cancelEdit) + @Test("Where the chat takes media, the row holds `+` with the camera and photos") + func plus() { + let row = row() + #expect(row.plusItems == [.camera, .photos]) + #expect(!row.isEmpty) + } + + @Test("Cash is never a menu row: the `$` beside the field takes it") + func cashLeavesTheMenu() { + #expect(!row().plusItems.contains(.cash)) + #expect(!row(acceptsMedia: false).plusItems.contains(.cash)) + } + + @Test("With no menu rows left, `+` goes", arguments: [false, true]) + func noPlus(isFull: Bool) { + let row = row(acceptsMedia: isFull, attachedCount: isFull ? ComposerModel.maxAttachments : 0) + #expect(row.plusItems.isEmpty) + #expect(row.isEmpty) + } + + @Test("An edit hides the bottom row") + func hiddenWhileEditing() { + #expect(row(isEditing: true).isEmpty) + } + + @Test("Before the chat exists there is no bottom row") + func hiddenBeforeChatExists() { + #expect(row(chatExists: false).isEmpty) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift index 3e0873de5..bb6f31551 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift @@ -8,7 +8,7 @@ import CoreGraphics /// The cards stacked above the composer row, as the bar reports them alongside its height. -public struct BarAccessories: Equatable, Sendable { +public nonisolated struct BarAccessories: Equatable, Sendable { /// A card that can stand above the composer row. public enum Kind: Hashable, Sendable { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index ad5c7a0c1..0e559c536 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -780,6 +780,10 @@ public final class ChatScreenViewController: UIViewController { holdForInPlaceResize(clipHeight: clipHeight) return } + if clipHeight < previousClip, !toggled, !isFirst, view.window != nil { + holdForShrink(clipHeight: clipHeight) + return + } transcriptCover = nil fadeTopConstraint?.constant = 0 @@ -804,6 +808,33 @@ public final class ChatScreenViewController: UIViewController { } } + /// Shrinks the bar's room for a composer folding back to one row or dropping its chips. + /// + /// The transcript takes the new inset in this frame, unanimated, so a send's insertion moves its + /// rows on its own spring alone. The clip stays at its old height until the glass's spring is + /// done: cut at once, it would hide the text and caret still travelling down to their place. + private func holdForShrink(clipHeight: CGFloat) { + let heldClip = barClipHeightConstraint.constant + transcriptCover = clipHeight + fadeTopConstraint.constant = heldClip - clipHeight + UIView.performWithoutAnimation { + self.view.layoutIfNeeded() + self.updateTranscriptInset() + } + let height = measuredBarHeight + DispatchQueue.main.asyncAfter(deadline: .now() + ChatMotion.replySurface.duration) { [weak self] in + // A later report may already have moved the bar on; only the one still current lands. + guard let self, self.measuredBarHeight == height, + self.barClipHeightConstraint.constant == heldClip else { return } + self.barClipHeightConstraint.constant = clipHeight + self.transcriptCover = nil + self.fadeTopConstraint.constant = 0 + UIView.performWithoutAnimation { + self.view.layoutIfNeeded() + } + } + } + /// Makes room for a change the bar draws itself, without animating anything. /// /// The glass grows and shrinks on its own spring, its bottom on the composer, and a second diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index 8ccb29c49..8a2217c80 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -34,6 +34,18 @@ extension View { } } + /// Dark-tinted glass for a control floating over a photo or viewfinder, where clear glass takes + /// on the image behind it and washes the glyph out. + @ViewBuilder + public func overlayGlassBackground(in shape: some Shape) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.tint(.black.opacity(0.45)).interactive(), in: shape) + } else { + background(Color.black.opacity(0.45), in: shape) + .background(.ultraThinMaterial, in: shape) + } + } + /// The app's glass surface for a panel that holds its own buttons: Liquid Glass on iOS 26, an /// ultra-thin material below. Non-interactive, because `.interactive()` on the container competes /// with its buttons for the tap. @@ -73,4 +85,34 @@ extension View { background(.ultraThinMaterial, in: .rect(cornerRadius: cornerRadius)) } } + + /// The chat composer's glass in `shape`, as a layer of its own: Liquid Glass with a light white + /// tint, joined by `id` to the other composer glass in an enclosing container. A tinted material + /// before iOS 26. + @ViewBuilder + public func composerGlass(in shape: some Shape, id: String, namespace: Namespace.ID) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.tint(Color.white.opacity(0.03)).interactive(), in: shape) + .glassEffectID(id, in: namespace) + } else { + background(Color.white.opacity(0.03), in: shape) + .background(.ultraThinMaterial, in: shape) + } + } + + /// The chat composer's light rim around `shape`, brightest along the top. + public func composerRim(in shape: some InsettableShape) -> some View { + overlay { + shape + .strokeBorder( + LinearGradient( + colors: [Color.white.opacity(0.18), Color.white.opacity(0.04)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + .allowsHitTesting(false) + } + } } From 776bd6d1d58e8ae19c0237c29e4d39975a8d555f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 09:35:04 -0400 Subject: [PATCH 2/2] docs(chat): drop the two-row composer plan It describes the two-row layout this PR replaced with one row. --- .claude/plans/2026-10-04-two-row-composer.md | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 .claude/plans/2026-10-04-two-row-composer.md diff --git a/.claude/plans/2026-10-04-two-row-composer.md b/.claude/plans/2026-10-04-two-row-composer.md deleted file mode 100644 index 0163c95c1..000000000 --- a/.claude/plans/2026-10-04-two-row-composer.md +++ /dev/null @@ -1,20 +0,0 @@ -# Two-row composer (E2) - -The composer field becomes two rows inside one glass field. - -- Top row: chip strip, then the message text, full width. -- Bottom row: 34pt `+` circle, grey "Send $" capsule, Spacer, white circular send button. -- Field padding 8 all round; the text row is inset 6 more on the leading side. - -## Decisions - -- `ConversationBarLeadingControl` keeps only what stands outside the field: `.cancelEdit`, `.sendCash` (pre-chat CTA), `.none`. -- New `ConversationBarBottomRow` (plus items, pill flag). Empty while editing and before the chat exists. Cash never appears in its menu: the pill replaces it. -- No bottom row (no `+`, no pill, or editing): single-row field with send on the text row. -- `AttachMenu` is restyled to the 34pt circle and rendered by `ConversationComposer`; panel toggle, frame reporting, stand-in and accessibility are unchanged. -- The attach surface stays an overlay on the bar's row; its collapsed radius is `plus.height / 2`. -- `BarMetrics.contentHeight` still means the single-row field. `BarMetrics.twoRowContentHeight` (84) is the two-row field. - -## Tests - -Bottom-row decision, Cash dropped from the menu, collapsed radius, bar height, and `AttachPanelPlacementTests` retargeted to `+`'s new column.