From a0970ee707fa2d452a04a1ce193526a24da48786 Mon Sep 17 00:00:00 2001 From: hrayleung Date: Sun, 10 May 2026 14:33:11 +0800 Subject: [PATCH 1/2] refactor: redesign composer quote cards as compact horizontal chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple quotes now sit side-by-side as fixed-size 160×76pt cards that scroll horizontally, instead of stacking full-width and pushing the editor down. The card itself drops the explicit "Replying to " header — that source info moves into the hover tooltip — so each card just shows the truncated quoted text against a subtle surface with a left accent bar. The dismiss button shrinks to 16pt and is hidden until hover, matching the minimal look in the new design. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/UI/CompactComposerOverlayRows.swift | 16 ++- Sources/UI/ExpandedComposerOverlay.swift | 7 +- Sources/UI/QuoteCardViews.swift | 125 ++++++++++---------- 3 files changed, 75 insertions(+), 73 deletions(-) diff --git a/Sources/UI/CompactComposerOverlayRows.swift b/Sources/UI/CompactComposerOverlayRows.swift index 2a7bb70..39dc1eb 100644 --- a/Sources/UI/CompactComposerOverlayRows.swift +++ b/Sources/UI/CompactComposerOverlayRows.swift @@ -52,14 +52,18 @@ extension CompactComposerOverlayView { @ViewBuilder var quoteCardsRow: some View { if !draftQuotes.isEmpty { - VStack(alignment: .leading, spacing: JinSpacing.xSmall + 2) { - ForEach(draftQuotes) { quote in - ComposerQuoteCardView(quote: quote) { - onRemoveQuote(quote) + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: JinSpacing.small) { + ForEach(draftQuotes) { quote in + ComposerQuoteCardView(quote: quote) { + onRemoveQuote(quote) + } + .equatable() + .transition(ComposerQuoteCardView.transition(reduceMotion: reduceMotion)) } - .equatable() - .transition(ComposerQuoteCardView.transition(reduceMotion: reduceMotion)) } + .padding(.horizontal, JinSpacing.xSmall) + .padding(.vertical, 2) } } } diff --git a/Sources/UI/ExpandedComposerOverlay.swift b/Sources/UI/ExpandedComposerOverlay.swift index 0f567e2..4a572ef 100644 --- a/Sources/UI/ExpandedComposerOverlay.swift +++ b/Sources/UI/ExpandedComposerOverlay.swift @@ -85,8 +85,8 @@ struct ExpandedComposerOverlay: View { if !draftQuotes.isEmpty { ExpandedComposerAccessorySection(title: "Quotes", systemName: "quote.opening") { - ScrollView(.vertical, showsIndicators: true) { - VStack(alignment: .leading, spacing: JinSpacing.xSmall + 2) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: JinSpacing.small) { ForEach(draftQuotes) { quote in ComposerQuoteCardView(quote: quote) { onRemoveQuote(quote) @@ -95,8 +95,9 @@ struct ExpandedComposerOverlay: View { .transition(ComposerQuoteCardView.transition(reduceMotion: reduceMotion)) } } + .padding(.horizontal, JinSpacing.xSmall) + .padding(.vertical, 2) } - .frame(maxHeight: 168) } } diff --git a/Sources/UI/QuoteCardViews.swift b/Sources/UI/QuoteCardViews.swift index ca1f861..6fd92c9 100644 --- a/Sources/UI/QuoteCardViews.swift +++ b/Sources/UI/QuoteCardViews.swift @@ -79,6 +79,8 @@ private struct QuoteCardContainer: View { let density: QuoteCardDensity let accessory: Accessory + @State private var isHovering = false + init( quote: QuoteContent, density: QuoteCardDensity = .message, @@ -149,78 +151,68 @@ private struct QuoteCardContainer: View { ) } - private var composerQuoteBody: some View { - VStack(alignment: .leading, spacing: 3) { - HStack(alignment: .center, spacing: JinSpacing.xSmall + 2) { - Image(systemName: "arrowshape.turn.up.left.fill") - .font(.system(size: 8.5, weight: .semibold)) - .foregroundStyle(QuoteCardPalette.accent) - - if let iconID = quote.sourceProviderIconID { - ProviderIconView(iconID: iconID, fallbackSystemName: "sparkles", size: 11) - .frame(width: 11, height: 11) - } - - headerLabel - - Spacer(minLength: JinSpacing.xSmall) + private var composerTooltip: String { + let header = composerHeader + let prefix: String + if let modelName = header.modelName { + prefix = "\(header.prefix) \(modelName)" + } else { + prefix = header.prefix + } + return "\(prefix)\n\n\(quote.quotedText)" + } - accessory - .layoutPriority(1) - } + private var composerQuoteBody: some View { + HStack(alignment: .top, spacing: 0) { + RoundedRectangle(cornerRadius: 1, style: .continuous) + .fill(QuoteCardPalette.accent) + .frame(width: 2) + .padding(.vertical, JinSpacing.small - 2) + .padding(.leading, JinSpacing.small - 2) Text(quote.quotedText) - .font(.callout) - .foregroundStyle(.primary.opacity(0.86)) - .lineLimit(2) + .font(.caption) + .foregroundStyle(.primary.opacity(0.85)) + .lineLimit(QuoteCardLayout.composerLineLimit) .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.leading, JinSpacing.small) + .padding(.trailing, JinSpacing.small) + .padding(.vertical, JinSpacing.small) } - .padding(.leading, JinSpacing.medium + 4) - .padding(.trailing, JinSpacing.small + 2) - .padding(.vertical, JinSpacing.small) - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) + .frame( + width: QuoteCardLayout.composerWidth, + height: QuoteCardLayout.composerHeight, + alignment: .topLeading + ) .background( - RoundedRectangle(cornerRadius: JinRadius.medium, style: .continuous) - .fill(JinSemanticColor.subtleSurface) + RoundedRectangle(cornerRadius: JinRadius.small, style: .continuous) + .fill(JinSemanticColor.subtleSurface.opacity(0.65)) ) - .overlay(alignment: .leading) { - RoundedRectangle(cornerRadius: 1.25, style: .continuous) - .fill(QuoteCardPalette.accent) - .frame(width: 2.5) - .padding(.vertical, JinSpacing.small) - .padding(.leading, JinSpacing.small) - } .overlay( - RoundedRectangle(cornerRadius: JinRadius.medium, style: .continuous) - .stroke(JinSemanticColor.separator.opacity(0.5), lineWidth: JinStrokeWidth.hairline) + RoundedRectangle(cornerRadius: JinRadius.small, style: .continuous) + .stroke(JinSemanticColor.separator.opacity(0.4), lineWidth: JinStrokeWidth.hairline) ) - } - - @ViewBuilder - private var headerLabel: some View { - let header = composerHeader - if let modelName = header.modelName { - HStack(spacing: 4) { - Text(header.prefix) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - Text(modelName) - .font(.caption.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - .truncationMode(.tail) - } - } else { - Text(header.prefix) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .lineLimit(1) + .overlay(alignment: .topTrailing) { + accessory + .opacity(isHovering ? 1 : 0) + .animation(.easeInOut(duration: 0.12), value: isHovering) + .padding(.top, 2) + .padding(.trailing, 2) } + .contentShape(RoundedRectangle(cornerRadius: JinRadius.small, style: .continuous)) + .onHover { isHovering = $0 } + .help(composerTooltip) } } +enum QuoteCardLayout { + static let composerWidth: CGFloat = 160 + static let composerHeight: CGFloat = 76 + static let composerLineLimit: Int = 4 +} + struct MessageQuoteCardView: View { let quote: QuoteContent @@ -264,16 +256,21 @@ private struct QuoteDismissButton: View { var body: some View { Button(action: action) { Image(systemName: "xmark") - .font(.system(size: 9, weight: .bold)) + .font(.system(size: 8, weight: .bold)) .foregroundStyle( isHovering - ? AnyShapeStyle(Color.primary.opacity(0.78)) - : AnyShapeStyle(HierarchicalShapeStyle.tertiary) + ? AnyShapeStyle(Color.primary.opacity(0.85)) + : AnyShapeStyle(Color.primary.opacity(0.55)) ) - .frame(width: 18, height: 18) + .frame(width: 16, height: 16) .background( Circle() - .fill(Color.primary.opacity(isHovering ? 0.08 : 0)) + .fill(.regularMaterial) + .opacity(isHovering ? 1 : 0.85) + ) + .overlay( + Circle() + .stroke(JinSemanticColor.separator.opacity(0.4), lineWidth: JinStrokeWidth.hairline) ) .contentShape(Rectangle()) } From 4ac3afd2e96acaa27ab7b9000cebf9911d757f76 Mon Sep 17 00:00:00 2001 From: hrayleung Date: Sun, 10 May 2026 14:41:34 +0800 Subject: [PATCH 2/2] fix: keep quote dismiss button visible to keyboard users The dismiss button was tied solely to .onHover, so a sighted keyboard user who Tab-focused it would land on something they couldn't see. Now the card surfaces the button whenever it has either pointer hover or keyboard focus, and the button itself shows an accent ring while focused so the destination is obvious. QuoteCardContainer takes the focus signal as a plain Bool so MessageQuoteCardView (which has no accessory) is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/UI/QuoteCardViews.swift | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/Sources/UI/QuoteCardViews.swift b/Sources/UI/QuoteCardViews.swift index 6fd92c9..4debc75 100644 --- a/Sources/UI/QuoteCardViews.swift +++ b/Sources/UI/QuoteCardViews.swift @@ -77,6 +77,7 @@ private enum QuoteCardDensity { private struct QuoteCardContainer: View { let quote: QuoteContent let density: QuoteCardDensity + let accessoryFocused: Bool let accessory: Accessory @State private var isHovering = false @@ -84,10 +85,12 @@ private struct QuoteCardContainer: View { init( quote: QuoteContent, density: QuoteCardDensity = .message, + accessoryFocused: Bool = false, @ViewBuilder accessory: () -> Accessory ) { self.quote = quote self.density = density + self.accessoryFocused = accessoryFocused self.accessory = accessory() } @@ -196,8 +199,9 @@ private struct QuoteCardContainer: View { ) .overlay(alignment: .topTrailing) { accessory - .opacity(isHovering ? 1 : 0) + .opacity(isHovering || accessoryFocused ? 1 : 0) .animation(.easeInOut(duration: 0.12), value: isHovering) + .animation(.easeInOut(duration: 0.12), value: accessoryFocused) .padding(.top, 2) .padding(.trailing, 2) } @@ -227,9 +231,15 @@ struct ComposerQuoteCardView: View, Equatable { let quote: DraftQuote let onRemove: () -> Void + @State private var isDismissFocused = false + var body: some View { - QuoteCardContainer(quote: quote.content, density: .composer) { - QuoteDismissButton(action: onRemove) + QuoteCardContainer( + quote: quote.content, + density: .composer, + accessoryFocused: isDismissFocused + ) { + QuoteDismissButton(action: onRemove, isFocused: $isDismissFocused) } } @@ -250,15 +260,17 @@ struct ComposerQuoteCardView: View, Equatable { private struct QuoteDismissButton: View { let action: () -> Void + @Binding var isFocused: Bool @State private var isHovering = false + @FocusState private var isButtonFocused: Bool var body: some View { Button(action: action) { Image(systemName: "xmark") .font(.system(size: 8, weight: .bold)) .foregroundStyle( - isHovering + isHovering || isButtonFocused ? AnyShapeStyle(Color.primary.opacity(0.85)) : AnyShapeStyle(Color.primary.opacity(0.55)) ) @@ -266,15 +278,24 @@ private struct QuoteDismissButton: View { .background( Circle() .fill(.regularMaterial) - .opacity(isHovering ? 1 : 0.85) + .opacity(isHovering || isButtonFocused ? 1 : 0.85) ) .overlay( Circle() - .stroke(JinSemanticColor.separator.opacity(0.4), lineWidth: JinStrokeWidth.hairline) + .stroke( + isButtonFocused + ? Color.accentColor.opacity(0.7) + : JinSemanticColor.separator.opacity(0.4), + lineWidth: isButtonFocused ? JinStrokeWidth.regular : JinStrokeWidth.hairline + ) ) .contentShape(Rectangle()) } .buttonStyle(.plain) + .focused($isButtonFocused) + .onChange(of: isButtonFocused) { _, newValue in + isFocused = newValue + } .onHover { isHovering = $0 } .accessibilityLabel("Remove quote") .help("Remove quote")