diff --git a/ios/Hapi/Features/Chat/Blocks/CodexReviewBlockView.swift b/ios/Hapi/Features/Chat/Blocks/CodexReviewBlockView.swift index d57ca6bd..8e34934c 100644 --- a/ios/Hapi/Features/Chat/Blocks/CodexReviewBlockView.swift +++ b/ios/Hapi/Features/Chat/Blocks/CodexReviewBlockView.swift @@ -122,7 +122,7 @@ private struct FindingRow: View { .font(.footnote) if let location = formatLocation(finding) { Text(location) - .font(.system(size: 10, design: .monospaced)) + .font(.caption.monospaced()) .foregroundStyle(.secondary) } } diff --git a/ios/Hapi/Features/Chat/Blocks/PermissionActionViews.swift b/ios/Hapi/Features/Chat/Blocks/PermissionActionViews.swift index 65a26685..5eed1fc5 100644 --- a/ios/Hapi/Features/Chat/Blocks/PermissionActionViews.swift +++ b/ios/Hapi/Features/Chat/Blocks/PermissionActionViews.swift @@ -35,6 +35,7 @@ private struct PermissionActionsRow: View { let tool: ChatToolCall let requestId: String let interactions: ChatInteractor + @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { let override = interactions.permissionOverrides[requestId] @@ -47,12 +48,16 @@ private struct PermissionActionsRow: View { let canAllowAllEdits = interactions.flavor == "claude" && PermissionGates.editTools.contains(tool.name) - HStack(spacing: 8) { + let layout = dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) + : AnyLayout(HStackLayout(spacing: 8)) + layout { Button { interactions.resolvePermission(requestId: requestId, action: .allow) } label: { Text("Allow") - .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(.bordered) .tint(.green) @@ -61,7 +66,8 @@ private struct PermissionActionsRow: View { interactions.resolvePermission(requestId: requestId, action: codex ? .abort : .deny) } label: { Text(codex ? String(localized: "Abort") : String(localized: "Deny")) - .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(.bordered) .tint(.red) @@ -83,7 +89,7 @@ private struct PermissionActionsRow: View { } } label: { Image(systemName: "ellipsis") - .frame(width: 28, height: 28) + .frame(minWidth: 44, minHeight: 44) .contentShape(Rectangle()) } } diff --git a/ios/Hapi/Features/Chat/Blocks/TextBlockViews.swift b/ios/Hapi/Features/Chat/Blocks/TextBlockViews.swift index 5ac1da2c..a675fbd1 100644 --- a/ios/Hapi/Features/Chat/Blocks/TextBlockViews.swift +++ b/ios/Hapi/Features/Chat/Blocks/TextBlockViews.swift @@ -16,6 +16,7 @@ struct UserTextBlockView: View { let block: UserTextBlock @Environment(\.chatInteractions) private var interactions + @Environment(\.hapiTypography) private var typography var body: some View { HStack(alignment: .bottom, spacing: 0) { @@ -24,7 +25,9 @@ struct UserTextBlockView: View { VStack(alignment: .leading, spacing: 8) { if !block.text.isEmpty { Text(block.text) - .font(.subheadline) + .font(typography.bodyFont) + .lineSpacing(typography.bodyLineSpacing) + .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) } if let attachments = block.attachments, !attachments.isEmpty { @@ -35,8 +38,8 @@ struct UserTextBlockView: View { } } } - .padding(.horizontal, 14) - .padding(.vertical, 10) + .padding(.horizontal, 16) + .padding(.vertical, 12) .background(.tint.opacity(0.16)) .clipShape( .rect( @@ -130,7 +133,7 @@ struct AgentEventBlockView: View { .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) .padding(.horizontal, 24) .padding(.vertical, 2) @@ -155,11 +158,13 @@ struct TerminalTextView: View { var isError = false @Environment(\.hapiTheme) private var theme + @Environment(\.hapiTypography) private var typography var body: some View { ScrollView(.horizontal, showsIndicators: false) { Text(text) - .font(.system(size: 12, design: .monospaced)) + .font(typography.codeFont) + .lineSpacing(typography.codeLineSpacing) .foregroundStyle(isError ? AnyShapeStyle(theme.danger) : AnyShapeStyle(theme.textPrimary)) .textSelection(.enabled) .padding(.horizontal, 12) diff --git a/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift b/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift index a0d0648f..36922144 100644 --- a/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift +++ b/ios/Hapi/Features/Chat/Blocks/ToolCallBlockView.swift @@ -16,6 +16,8 @@ struct ToolCallBlockView: View { @ChatStoredState private var expanded: Bool @ChatStoredState private var childrenOpen: Bool @Environment(\.hapiTheme) private var theme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ScaledMetric(relativeTo: .footnote) private var iconWidth: CGFloat = 18 @Environment(\.chatInteractions) private var interactions init(block: ToolCallBlock, basePath: String?) { @@ -79,28 +81,37 @@ struct ToolCallBlockView: View { expanded.toggle() } } label: { - HStack(spacing: 8) { - Image(systemName: presentation.icon) - .font(.footnote) - .foregroundStyle(.secondary) - .frame(width: 18) - VStack(alignment: .leading, spacing: 1) { - Text(presentation.title) - .font(.subheadline) - .foregroundStyle(theme.textPrimary) - .lineLimit(1) - if let subtitle = presentation.subtitle { - Text(subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) + let layout = dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) + : AnyLayout(HStackLayout(spacing: 8)) + layout { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: presentation.icon) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(width: iconWidth) + VStack(alignment: .leading, spacing: 4) { + Text(presentation.title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(theme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + if let subtitle = presentation.subtitle { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + // Full commands belong in the expanded body, not above approval controls. + .lineLimit(2) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + } } } - Spacer(minLength: 8) + if !dynamicTypeSize.isAccessibilitySize { Spacer(minLength: 8) } ToolStatusIndicator(state: block.tool.state) } .padding(.horizontal, 10) .padding(.vertical, 8) + .frame(minHeight: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -175,7 +186,7 @@ private struct StatusChip: View { var body: some View { Text(text) - .font(.caption2) + .font(.footnote) .foregroundStyle(tint) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -220,7 +231,7 @@ private struct PermissionLine: View { var body: some View { Text(text) - .font(.caption2) + .font(.footnote) .foregroundStyle(isError ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) .padding(.horizontal, 10) .padding(.bottom, 6) diff --git a/ios/Hapi/Features/Chat/Blocks/ToolGroupBlockView.swift b/ios/Hapi/Features/Chat/Blocks/ToolGroupBlockView.swift index 66573f6e..59fe8c62 100644 --- a/ios/Hapi/Features/Chat/Blocks/ToolGroupBlockView.swift +++ b/ios/Hapi/Features/Chat/Blocks/ToolGroupBlockView.swift @@ -14,6 +14,8 @@ struct ToolGroupBlockView: View { private let expansion: Binding? private let showsTools: Bool @Environment(\.hapiTheme) private var theme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ScaledMetric(relativeTo: .footnote) private var iconWidth: CGFloat = 18 init(block: ToolGroupBlock, basePath: String?, expansion: Binding? = nil, showsTools: Bool = true) { self.block = block @@ -48,28 +50,34 @@ struct ToolGroupBlockView: View { else { expanded.toggle() } } } label: { - HStack(spacing: 8) { - Image(systemName: "wrench.and.screwdriver") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(width: 18) - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(.subheadline) - .foregroundStyle(theme.textPrimary) - .lineLimit(1) - if !summaryText.isEmpty { - Text(summaryText) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) + let layout = dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) + : AnyLayout(HStackLayout(spacing: 8)) + layout { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "wrench.and.screwdriver") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(width: iconWidth) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.subheadline.weight(.medium)) + .foregroundStyle(theme.textPrimary) + .fixedSize(horizontal: false, vertical: true) + if !summaryText.isEmpty { + Text(summaryText) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } } - Spacer(minLength: 8) + if !dynamicTypeSize.isAccessibilitySize { Spacer(minLength: 8) } trailingIndicator } .padding(.horizontal, 10) .padding(.vertical, 8) + .frame(minHeight: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -108,7 +116,7 @@ struct ToolGroupBlockView: View { .controlSize(.small) } else if block.summary.errorCount > 0 { Text("\(block.summary.errorCount) ⚠") - .font(.caption2) + .font(.footnote) .foregroundStyle(.red) } else { Image(systemName: (expansion?.wrappedValue ?? expanded) ? "chevron.down" : "chevron.right") diff --git a/ios/Hapi/Features/Chat/ChatTranscriptView.swift b/ios/Hapi/Features/Chat/ChatTranscriptView.swift index 5126b6b0..dca01934 100644 --- a/ios/Hapi/Features/Chat/ChatTranscriptView.swift +++ b/ios/Hapi/Features/Chat/ChatTranscriptView.swift @@ -10,6 +10,16 @@ private enum TranscriptRow: Identifiable, Equatable { case group(ToolGroupBlock, Bool) static let historyID = "chat-history-control" + var role: TranscriptRowRole { + switch self { + case .history: .history + case .group: .tool + case .message(.toolGroup): .tool + case .message(.block(.userText)): .user + case .message(.block(.toolCall)): .tool + default: .content + } + } var id: String { switch self { case .history: Self.historyID @@ -47,7 +57,8 @@ struct ChatTranscriptView: View { onViewport: { viewport in model.readingViewportChanged(followsTail: viewport.followsTail, needsOlder: viewport.needsOlder) }, - onLayout: { version, progress in model.historyLaidOut(version: version, madeProgress: progress) } + onLayout: { version, progress in model.historyLaidOut(version: version, madeProgress: progress) }, + spacingBefore: { previous, row in row.role.spacing(after: previous?.role) } ) { row in // The list bridges the current environment into its hosting roots. AnyView(rowView(row)) @@ -93,7 +104,7 @@ struct ChatTranscriptView: View { } .font(.caption) .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44) + .frame(maxWidth: .infinity, minHeight: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/ios/Hapi/Features/Chat/ChatView.swift b/ios/Hapi/Features/Chat/ChatView.swift index ba35baf4..01060db8 100644 --- a/ios/Hapi/Features/Chat/ChatView.swift +++ b/ios/Hapi/Features/Chat/ChatView.swift @@ -68,6 +68,7 @@ struct ChatView: View { .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { QueuedMessagesBarView(interactor: model.interactor) + .hapiReadingColumn() ChatComposerView(interactor: model.interactor, dictation: model.dictation) } } diff --git a/ios/Hapi/Features/Chat/Composer/ChatComposerView.swift b/ios/Hapi/Features/Chat/Composer/ChatComposerView.swift index 1498ab5b..a90af76c 100644 --- a/ios/Hapi/Features/Chat/Composer/ChatComposerView.swift +++ b/ios/Hapi/Features/Chat/Composer/ChatComposerView.swift @@ -1,5 +1,6 @@ import AVFAudio import HapiClient +import HapiUI import PhotosUI import SwiftUI import UIKit @@ -11,6 +12,7 @@ import UniformTypeIdentifiers /// otherwise Send (long-press offers "Send & steer" during a turn). The card /// also hosts dictation and attachment controls. struct ChatComposerView: View { + @Environment(\.hapiTypography) private var typography let interactor: ChatInteractor /// nil ⇒ dictation unavailable (no controller wired) — mic button hidden. var dictation: DictationController? @@ -59,6 +61,7 @@ struct ChatComposerView: View { .padding(.top, 8) } TextField("Message the agent…", text: text, axis: .vertical) + .font(typography.bodyFont) .lineLimit(1...6) .textFieldStyle(.plain) .padding(.horizontal, 12) @@ -82,9 +85,9 @@ struct ChatComposerView: View { .fill(composerSurfaceColor) ) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(0.12), radius: 8, y: 2) + .shadow(color: .black.opacity(0.06), radius: 6, y: 2) } - .padding(.horizontal, 12) + .hapiReadingColumn() .padding(.vertical, 8) .task(id: dictation.map { ObjectIdentifier($0) }) { await dictation?.refreshAvailability() @@ -207,7 +210,7 @@ struct ChatComposerView: View { foreground: AnyShapeStyle(.secondary) ) { Image(systemName: "plus") - .font(.subheadline.weight(.medium)) + .font(.system(size: 15, weight: .medium)) } } .buttonStyle(.plain) @@ -236,7 +239,7 @@ struct ChatComposerView: View { .controlSize(.small) } else { Image(systemName: recording ? "stop.fill" : "mic.fill") - .font(.subheadline) + .font(.system(size: 15)) } } } @@ -313,7 +316,7 @@ struct ChatComposerView: View { foreground: AnyShapeStyle(.white) ) { Image(systemName: "stop.fill") - .font(.subheadline) + .font(.system(size: 15)) } } .buttonStyle(.plain) @@ -351,10 +354,12 @@ struct ChatComposerView: View { foreground: enabled ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary) ) { Image(systemName: "arrow.up") - .font(.subheadline.weight(.semibold)) + .font(.system(size: 15, weight: .semibold)) } } + // Icon-only controls keep their 38pt visuals / 44pt hit areas. Their + // symbols use fixed sizes; editable text remains fully Dynamic Type. private func actionCircle( background: AnyShapeStyle, foreground: AnyShapeStyle, diff --git a/ios/Hapi/Features/Files/FileViewerView.swift b/ios/Hapi/Features/Files/FileViewerView.swift index 09f0240b..a69b0ad2 100644 --- a/ios/Hapi/Features/Files/FileViewerView.swift +++ b/ios/Hapi/Features/Files/FileViewerView.swift @@ -194,7 +194,8 @@ struct FileViewerView: View { case .text(let text, let language, let isMarkdown): if isMarkdown && model.markdownPreview { MarkdownView(markdown: text) - .frame(maxWidth: .infinity, alignment: .leading) + // The outer viewer already supplies 12pt on each side. + .hapiReadingColumn(horizontalInset: 4) } else { CodeBlockView(language: language, code: text) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/ios/Hapi/RootView.swift b/ios/Hapi/RootView.swift index b3950680..0a77ccd3 100644 --- a/ios/Hapi/RootView.swift +++ b/ios/Hapi/RootView.swift @@ -61,6 +61,7 @@ struct RootView: View { } message: { Text(model.infoNotice ?? "") } + .hapiTypography() .hapiTheme(themePrefs.mode.hapiTheme(systemColorScheme: colorScheme)) // Explicit modes force the presentation's chrome to match their // palette; `.system` passes nil so the OS scheme flows through. diff --git a/ios/HapiTests/ToolCallLayoutTests.swift b/ios/HapiTests/ToolCallLayoutTests.swift new file mode 100644 index 00000000..f0e8bcc4 --- /dev/null +++ b/ios/HapiTests/ToolCallLayoutTests.swift @@ -0,0 +1,59 @@ +import HapiUI +import SwiftUI +import UIKit +import XCTest +@testable import Hapi +@testable import HapiProtocol + +@MainActor +final class ToolCallLayoutTests: XCTestCase { + private let prefix = "cat <<'EOF'\nx" + private var script: String { prefix + String(repeating: "\nx", count: 100) + "\nEOF" } + + private func block(name: String, command: String) -> ToolCallBlock { + // Cover both string commands and Codex's array representation. + let input: JSONValue = name == "CodexBash" + ? .array([.string("cat"), .string(String(command.dropFirst(4)))]) + : .string(command) + return ToolCallBlock( + id: "tool", localId: nil, createdAt: 0, invokedAt: nil, + durationMs: nil, usage: nil, model: nil, + tool: ChatToolCall(id: "tool", name: name, state: .completed, + input: .object(["command": input]), createdAt: 0), + children: [], meta: nil + ) + } + + private func height(of block: ToolCallBlock, size: DynamicTypeSize, expanded: Bool = false) -> CGFloat { + let state = ChatPresentationState() + state.values[.init(id: block.id, field: "expanded")] = expanded + let host = UIHostingController(rootView: ToolCallBlockView(block: block, basePath: nil) + .environment(\.chatPresentationState, state) + .hapiTypography() + .environment(\.dynamicTypeSize, size)) + return host.sizeThatFits(in: CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude)).height + } + + func testCollapsedCommandPreviewIsBoundedToTwoLines() { + for name in ["Bash", "CodexBash"] { + for size in [DynamicTypeSize.large, .accessibility5] { + let oneLine = height(of: block(name: name, command: "cat <<'EOF'"), size: size) + let twoLines = height(of: block(name: name, command: prefix), size: size) + let longScript = height(of: block(name: name, command: script), size: size) + XCTAssertGreaterThan(twoLines, oneLine, "\(name), \(size): preview should allow two lines") + XCTAssertEqual(longScript, twoLines, accuracy: 1, "\(name), \(size): collapsed script must not grow past its preview") + } + } + } + + func testExpandedCommandKeepsTheCompleteScript() { + for name in ["Bash", "CodexBash"] { + let longBlock = block(name: name, command: script) + XCTAssertEqual(toolCardPresentation(longBlock.tool, basePath: nil).subtitle, script) + XCTAssertEqual(chatTerminalCommand(longBlock.tool.input), script) + let shortHeight = height(of: block(name: name, command: prefix + "\nEOF"), size: .large, expanded: true) + let fullHeight = height(of: longBlock, size: .large, expanded: true) + XCTAssertGreaterThan(fullHeight, shortHeight + 1000, "\(name): expanded body must retain all 100 extra lines") + } + } +} diff --git a/ios/HapiTests/TranscriptGeometryTests.swift b/ios/HapiTests/TranscriptGeometryTests.swift index 57a45843..a4f67171 100644 --- a/ios/HapiTests/TranscriptGeometryTests.swift +++ b/ios/HapiTests/TranscriptGeometryTests.swift @@ -22,10 +22,11 @@ final class TranscriptGeometryTests: XCTestCase { layout.prepare() @MainActor func verify(width: CGFloat) { - var y: CGFloat = 10 + var y: CGFloat = 12 + let contentWidth = HapiReadingLayout.contentWidth(in: width) let expected = ids.map { id -> CGRect in - let frame = CGRect(x: 12, y: y, width: width - 24, height: heights[id] ?? 100) - y = frame.maxY + 10 + let frame = CGRect(x: (width - contentWidth) / 2, y: y, width: contentWidth, height: heights[id] ?? 100) + y = frame.maxY + 12 return frame } let actualHeight = layout.collectionViewContentSize.height @@ -57,6 +58,32 @@ final class TranscriptGeometryTests: XCTestCase { XCTAssertEqual(collection.collectionViewLayout, layout) } + func testMetricsInvalidateOffscreenHeightsButTabletRecenteringDoesNot() throws { + let layout = TranscriptLayout() + let collection = UICollectionView(frame: CGRect(x: 0, y: 0, width: 1024, height: 768), collectionViewLayout: layout) + let ids = (0..<80).map(String.init) + let spacing = ids.indices.map { $0 == 30 ? CGFloat(24) : 12 } + layout.setItems(ids, width: 1024, spacingBefore: spacing) + layout.prepare() + let original = try XCTUnwrap(layout.layoutAttributesForItem(at: IndexPath(item: 30, section: 0))) + let preferred = original.copy() as! UICollectionViewLayoutAttributes + preferred.size.height = 450 + _ = layout.invalidationContext(forPreferredLayoutAttributes: preferred, withOriginalAttributes: original) + layout.prepare() + layout.setItems(ids, width: 768, spacingBefore: spacing) + layout.prepare() + let centered = try XCTUnwrap(layout.layoutAttributesForItem(at: original.indexPath)) + XCTAssertEqual(centered.frame.width, 720) + XCTAssertEqual(centered.frame.minX, 24) + XCTAssertEqual(centered.frame.height, 450, "Same reading width retains valid heights") + let preceding = try XCTUnwrap(layout.layoutAttributesForItem(at: IndexPath(item: 29, section: 0))) + XCTAssertEqual(centered.frame.minY - preceding.frame.maxY, 24) + layout.setItems(ids, width: 768, spacingBefore: spacing, invalidateMeasurements: true) + layout.prepare() + XCTAssertEqual(layout.layoutAttributesForItem(at: original.indexPath)?.frame.height, 100) + XCTAssertEqual(collection.collectionViewLayout, layout) + } + #if DEBUG func testVisibleQueriesVisitOnlyAViewportSizedRange() { let layout = TranscriptLayout() diff --git a/ios/HapiTests/TranscriptTypographyTests.swift b/ios/HapiTests/TranscriptTypographyTests.swift new file mode 100644 index 00000000..bc0790ae --- /dev/null +++ b/ios/HapiTests/TranscriptTypographyTests.swift @@ -0,0 +1,185 @@ +import Observation +import SwiftUI +import UIKit +import XCTest +@testable import HapiUI + +@MainActor +final class TranscriptTypographyTests: XCTestCase { + struct Row: Identifiable, Equatable { + let id: String + let text: String + } + + @Observable @MainActor + final class Driver { + let rows = (0..<80).map { + Row(id: String($0), text: "Message \($0) · 中英文阅读。" + String(repeating: "Dynamic Type keeps long answers readable. ", count: 3)) + } + var size = DynamicTypeSize.large + var weight: LegibilityWeight? = .regular + var width: CGFloat = 390 + var version = 0 + @ObservationIgnored var layouts: [Int] = [] + @ObservationIgnored var typography: HapiTypography? + } + + private struct Probe: UIViewRepresentable { + let driver: Driver + func makeUIView(context: Context) -> UIView { UIView() } + func updateUIView(_ view: UIView, context: Context) { + driver.typography = context.environment.hapiTypography + } + } + + private struct TextRow: View { + let row: Row + @Environment(\.hapiTypography) private var typography + var body: some View { + Text(row.text) + .font(typography.bodyFont) + .lineSpacing(typography.bodyLineSpacing) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private struct Harness: View { + let driver: Driver + var body: some View { + AnchoredTranscriptList(items: driver.rows, historyVersion: driver.version, jumpToken: 0, + historyControlID: "history", onViewport: { _ in }, + onLayout: { version, _ in driver.layouts.append(version) }) { row in + AnyView(TextRow(row: row)) + } + .overlay { Probe(driver: driver).frame(width: 0, height: 0) } + .frame(width: driver.width, height: 700) + .hapiTypography() + .environment(\.dynamicTypeSize, driver.size) + .environment(\.legibilityWeight, driver.weight) + } + } + + private func host(_ driver: Driver) throws -> (UIWindow, UICollectionView) { + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + let host = UIHostingController(rootView: Harness(driver: driver)) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + func find(_ view: UIView) -> UICollectionView? { + if let collection = view as? UICollectionView { return collection } + return view.subviews.lazy.compactMap(find).first + } + return (window, try XCTUnwrap(find(host.view))) + } + + private func settle(_ condition: () -> Bool = { true }, file: StaticString = #filePath, line: UInt = #line) async { + for _ in 0..<100 { + try? await Task.sleep(for: .milliseconds(20)) + if condition() { + try? await Task.sleep(for: .milliseconds(160)) + return + } + } + XCTFail("Typography layout did not settle", file: file, line: line) + } + + private func browse(_ view: UICollectionView, index: Int) async throws -> UICollectionViewCell { + view.delegate?.scrollViewWillBeginDragging?(view) + view.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false) + await settle() + view.contentOffset.y += 31 + view.layoutIfNeeded() + await settle() + return try XCTUnwrap(view.cellForItem(at: IndexPath(item: index, section: 0))) + } + + func testEqualItemsResizeAtLargeTextAndKeepTheHistoryAnchor() async throws { + let driver = Driver() + let (window, view) = try host(driver) + defer { window.isHidden = true } + await settle { driver.layouts.contains(0) } + XCTAssertEqual(try XCTUnwrap(driver.typography).bodySize, 16, accuracy: 0.01) + let farHeight = try await browse(view, index: 60).frame.height + let cell = try await browse(view, index: 25) + let oldHeight = cell.frame.height + let offset = cell.frame.minY - view.contentOffset.y + driver.size = .xxxLarge + await settle { (driver.typography?.bodySize ?? 0) > 16 && cell.frame.height > oldHeight } + XCTAssertEqual(cell.frame.minY - view.contentOffset.y, offset, accuracy: 1) + let typography = try XCTUnwrap(driver.typography) + XCTAssertEqual(typography.inlineCodeSize / typography.bodySize, 15 / 16, accuracy: 0.001) + let recycled = try await browse(view, index: 60) + XCTAssertGreaterThan(recycled.frame.height, farHeight) + } + + func testMaximumAccessibilitySizeAndBoldTextPreserveBottomFollowing() async throws { + let driver = Driver() + let (window, view) = try host(driver) + defer { window.isHidden = true } + await settle { driver.layouts.contains(0) } + driver.size = .accessibility5 + driver.weight = .bold + await settle { (driver.typography?.bodySize ?? 0) > 40 && driver.typography?.boldText == true } + XCTAssertEqual(view.contentOffset.y, view.contentSize.height - view.bounds.height, accuracy: 1) + let cell = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 79, section: 0))) + XCTAssertGreaterThan(cell.frame.height, 500) + driver.size = .large + await settle { driver.typography?.bodySize == 16 } + XCTAssertEqual(view.contentOffset.y, view.contentSize.height - view.bounds.height, accuracy: 1) + } + + func testShrinkingTextClampsAnOffsetBeyondTheNewRowHeight() async throws { + let driver = Driver() + driver.size = .accessibility5 + let (window, view) = try host(driver) + defer { window.isHidden = true } + await settle { driver.layouts.contains(0) } + let cell = try await browse(view, index: 25) + view.contentOffset.y = cell.frame.minY + 400 + view.layoutIfNeeded() + await settle() + driver.size = .large + await settle { driver.typography?.bodySize == 16 } + let resized = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0))) + XCTAssertEqual(resized.frame.maxY - view.contentOffset.y, 1, accuracy: 1) + } + + func testGrowingTextDoesNotClampADeepOffsetToAnUnmeasuredEstimate() async throws { + let driver = Driver() + driver.size = .accessibility3 + let (window, view) = try host(driver) + defer { window.isHidden = true } + await settle { driver.layouts.contains(0) } + let cell = try await browse(view, index: 25) + view.contentOffset.y = cell.frame.minY + 350 + view.layoutIfNeeded() + await settle() + let previousSize = try XCTUnwrap(driver.typography).bodySize + driver.size = .accessibility5 + await settle { (driver.typography?.bodySize ?? 0) > previousSize } + let grown = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0))) + XCTAssertEqual(view.contentOffset.y - grown.frame.minY, 350, accuracy: 1) + } + + func testReadingWidthAndBoldTextChangesKeepHistoryInPlace() async throws { + let driver = Driver() + let (window, view) = try host(driver) + defer { window.isHidden = true } + await settle { driver.layouts.contains(0) } + let cell = try await browse(view, index: 25) + let offset = cell.frame.minY - view.contentOffset.y + for width: CGFloat in [1024, 768, 507, 320] { + driver.width = width + await settle { abs(view.bounds.width - width) < 1 } + let frame = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0))).frame + XCTAssertEqual(frame.width, HapiReadingLayout.contentWidth(in: width), accuracy: 1) + XCTAssertEqual(frame.midX, width / 2, accuracy: 1) + XCTAssertEqual(frame.minY - view.contentOffset.y, offset, accuracy: 1) + } + driver.weight = .bold + await settle { driver.typography?.boldText == true } + let frame = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0))).frame + XCTAssertEqual(frame.minY - view.contentOffset.y, offset, accuracy: 1) + } +} diff --git a/ios/HapiTests/TypographySnapshotTests.swift b/ios/HapiTests/TypographySnapshotTests.swift new file mode 100644 index 00000000..2f61cb5c --- /dev/null +++ b/ios/HapiTests/TypographySnapshotTests.swift @@ -0,0 +1,170 @@ +import HapiUI +import HapiClient +import SwiftUI +import UIKit +import XCTest +@testable import Hapi +@testable import HapiProtocol + +/// Opt-in visual specimens, not product screenshots or pixel-golden tests. +@MainActor +final class TypographySnapshotTests: XCTestCase { + private struct Specimen: View { + let width: CGFloat + let theme: HapiTheme + let baseline: Bool + let interactor: ChatInteractor + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("HAPI · Typography specimen / 排版测试") + .font(.caption).foregroundStyle(.secondary) + UserTextBlockView(block: UserTextBlock( + id: "user", localId: nil, createdAt: 0, invokedAt: nil, + text: "请解释这些修改,并给出验证步骤。Explain the changes and how to verify them.", + attachments: nil, status: nil, originalText: nil, meta: nil + )) + MarkdownView(markdown: """ + ## 更清晰的阅读体验 + + 统一字体与留白,让长回答更容易阅读。Native typography keeps **important information** clear, including 中文、English、数字 123 和 emoji 👩🏽‍💻. + + ### Verification / 验证 + + 1. 调整系统文字大小,检查 `Dynamic Type` 与正文比例。 + 2. Read a longer response without losing your place while new messages arrive. + - Keep nested lists aligned with their first text baseline. + - [x] 保留链接 [documentation](https://example.com) 和代码样式。 + + > 正文应该是视觉主角。Controls should support the content, not compete with it. + + ```text + bun run test --filter typography + let message = "你好,HAPI 👋" + a_long_command --workspace /projects/native-client --output readable-results.json + ``` + + | 内容 | 验证重点 | + | --- | --- | + | 正文 Body | 中英文混排、换行与段落间距 | + | 代码 Code | 等宽、可读性与横向滚动 | + """) + DiffTextView(unifiedDiff: """ + --- a/reading.swift + +++ b/reading.swift + @@ -1,2 +1,2 @@ + -let body = 15 + +let body = 17 + // 阅读体验 / readable text + """) + ToolCallBlockView(block: ToolCallBlock( + id: "tool", localId: nil, createdAt: 0, invokedAt: nil, + durationMs: nil, usage: nil, model: nil, + tool: ChatToolCall(id: "tool", name: "Bash", state: .pending, + input: .object(["command": .string("git diff -- ios/Packages/HapiKit/Sources/HapiUI")]), + createdAt: 0, permission: ToolPermission(id: "approval", status: .pending)), + children: [], meta: nil + ), basePath: "/workspace/hapi") + .environment(\.chatInteractions, interactor) + } + .modifier(SpecimenColumn(baseline: baseline)) + .padding(.vertical, 16) + .frame(width: width, alignment: .leading) + .background(theme.background) + .hapiTheme(theme) + } + } + + private struct NoHTTP: HTTPPerforming { + func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + throw URLError(.notConnectedToInternet) + } + } + + private func makeInteractor() -> ChatInteractor { + let baseURL = URL(string: "http://127.0.0.1:1")! + let auth = AuthManager(baseURL: baseURL, credentialStore: InMemoryCredentialStore(), performer: NoHTTP()) + let api = APIClient(baseURL: baseURL, authManager: auth, performer: NoHTTP()) + // Never activated; no network, real credentials, or saved drafts. + return ChatInteractor(sessionId: "typography-specimen", api: api, + sessionStore: SessionListStore(api: api), windows: MessageWindowControllers(provider: api)) + } + + private struct SpecimenColumn: ViewModifier { + let baseline: Bool + func body(content: Content) -> some View { + if baseline { + content.padding(.horizontal, 12) + } else { + content.hapiReadingColumn() + } + } + } + + func testCaptureReadingSpecimens() async throws { + let path = ProcessInfo.processInfo.environment["HAPI_TYPOGRAPHY_CAPTURE"] + try XCTSkipIf(path == nil, "Opt in with TEST_RUNNER_HAPI_TYPOGRAPHY_CAPTURE=/tmp/…") + let directory = URL(fileURLWithPath: try XCTUnwrap(path), isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let cases: [(String, CGFloat, DynamicTypeSize, HapiTheme)] = [ + ("phone-small", 320, .large, .light), + ("phone-light", 390, .large, .light), + ("phone-dark", 390, .large, .dark), + ("phone-oled", 390, .large, .oled), + ("phone-xxxl", 430, .xxxLarge, .dark), + ("phone-ax5", 390, .accessibility5, .light), + ("ipad", 1024, .large, .light), + ("ipad-xxxl", 768, .xxxLarge, .dark), + ("split-ax5", 507, .accessibility5, .oled), + ] + for (name, width, size, theme) in cases { + let height: CGFloat = width >= 768 ? 1024 : 844 + let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.frame = CGRect(x: 0, y: 0, width: width, height: height) + let baseline = ProcessInfo.processInfo.environment["HAPI_TYPOGRAPHY_BASELINE"] == "1" + let interactor = makeInteractor() + let root = ScrollView { + Specimen(width: width, theme: theme, baseline: baseline, interactor: interactor) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + ChatComposerView(interactor: interactor) + } + .frame(width: width, height: height) + .background(theme.background) + .hapiTypography() + .hapiTheme(theme) + .environment(\.dynamicTypeSize, size) + .environment(\.colorScheme, theme.isDark ? .dark : .light) + let host = UIHostingController(rootView: root + .ignoresSafeArea() + .environment(\.dynamicTypeSize, size) + .environment(\.colorScheme, theme.isDark ? .dark : .light)) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.frame = window.bounds + host.view.layoutIfNeeded() + defer { window.isHidden = true } + try await Task.sleep(for: .milliseconds(250)) + func find(_ view: UIView) -> UIScrollView? { + if let scroll = view as? UIScrollView { return scroll } + return view.subviews.lazy.compactMap(find).first + } + let scroll = try XCTUnwrap(find(host.view)) + let format = UIGraphicsImageRendererFormat() + format.scale = 2 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format) + for (region, fraction) in [("top", CGFloat(0)), ("middle", CGFloat(0.5)), ("bottom", CGFloat(1))] { + let top = -scroll.adjustedContentInset.top + let bottom = max(top, scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom) + scroll.setContentOffset(CGPoint(x: 0, y: top + (bottom - top) * fraction), animated: false) + try await Task.sleep(for: .milliseconds(150)) + let image = renderer.image { _ in + host.view.drawHierarchy(in: CGRect(x: 0, y: 0, width: width, height: height), afterScreenUpdates: true) + } + let data = try XCTUnwrap(image.pngData(), name) + try data.write(to: directory.appendingPathComponent(name + "-" + region + ".png")) + } + } + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift b/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift index 2c537803..c8bf92d0 100644 --- a/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift +++ b/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift @@ -19,12 +19,14 @@ public struct AnchoredTranscriptList: UIViewCont public var historyControlID: String public var onViewport: (TranscriptViewport) -> Void public var onLayout: (Int, Bool) -> Void + public var spacingBefore: (Item?, Item) -> CGFloat public var content: (Item) -> AnyView fileprivate var environment = EnvironmentValues() public init(items: [Item], historyVersion: Int, jumpToken: Int, historyControlID: String, onViewport: @escaping (TranscriptViewport) -> Void, onLayout: @escaping (Int, Bool) -> Void, + spacingBefore: @escaping (Item?, Item) -> CGFloat = { _, _ in 12 }, content: @escaping (Item) -> AnyView) { self.items = items self.historyVersion = historyVersion @@ -32,6 +34,7 @@ public struct AnchoredTranscriptList: UIViewCont self.historyControlID = historyControlID self.onViewport = onViewport self.onLayout = onLayout + self.spacingBefore = spacingBefore self.content = content } @@ -60,9 +63,14 @@ final class TranscriptLayout: UICollectionViewLayout { private var ids: [String] = [] private var indicesByID: [String: Int] = [:] private var heights: [String: CGFloat] = [:] + // Preserve enough estimated space for the current partial row to remain + // visible while invalidated measurements are replaced. This is not a + // cached measurement and must never be used to clamp a reading offset. + private var anchorHeightEstimate: (id: String, height: CGFloat)? private var frames: [CGRect] = [] private var attributes: [UICollectionViewLayoutAttributes?] = [] private var width: CGFloat = 0 + private var spacingBefore: [CGFloat] = [] private var contentHeight: CGFloat = 0 private var dirtyFromIndex: Int? = 0 #if DEBUG @@ -70,11 +78,21 @@ final class TranscriptLayout: UICollectionViewLayout { private(set) var visibleQueryProbeCount = 0 #endif - func setItems(_ ids: [String], width: CGFloat) { + func setItems(_ ids: [String], width: CGFloat, spacingBefore: [CGFloat]? = nil, + invalidateMeasurements: Bool = false) { + let spacing = spacingBefore ?? Array(repeating: CGFloat(12), count: ids.count) + precondition(spacing.count == ids.count) let structureChanged = self.ids != ids - guard structureChanged || self.width != width else { return } - if abs(self.width - width) > 0.5 { heights.removeAll() } + guard structureChanged || self.width != width || self.spacingBefore != spacing || invalidateMeasurements else { return } + if invalidateMeasurements || abs(HapiReadingLayout.contentWidth(in: self.width) - HapiReadingLayout.contentWidth(in: width)) > 0.5 { + anchorHeightEstimate = anchorForUpdate.flatMap { anchor in + guard let index = indicesByID[anchor.id], frames.indices.contains(index) else { return nil } + return (anchor.id, max(frames[index].height, 1 - anchor.offset)) + } + heights.removeAll() + } self.width = width + self.spacingBefore = spacing if structureChanged { self.ids = ids indicesByID.removeAll(keepingCapacity: true) @@ -87,23 +105,31 @@ final class TranscriptLayout: UICollectionViewLayout { invalidateLayout() } + private func estimatedHeight(for id: String) -> CGFloat { + if let height = heights[id] { return height } + if let estimate = anchorHeightEstimate, estimate.id == id { return estimate.height } + return 100 + } + override func prepare() { super.prepare() guard let start = dirtyFromIndex else { return } dirtyFromIndex = nil - var y: CGFloat = start == 0 ? 10 : frames[start - 1].maxY + 10 + var y: CGFloat = start == 0 ? 0 : frames[start - 1].maxY + let contentWidth = HapiReadingLayout.contentWidth(in: width) for index in start.. 0.5 - let changed = next.items.filter { widthChanged || values[$0.id] != $0 }.map(\.id) + let contentWidthChanged = abs(HapiReadingLayout.contentWidth(in: configuredWidth) + - HapiReadingLayout.contentWidth(in: collection.bounds.width)) > 0.5 + let metricsChanged = configuration.map { + $0.environment.dynamicTypeSize != next.environment.dynamicTypeSize + || $0.environment.legibilityWeight != next.environment.legibilityWeight + || $0.environment.hapiTypography != next.environment.hapiTypography + || $0.environment.locale != next.environment.locale + || $0.environment.layoutDirection != next.environment.layoutDirection + } ?? true + let spacing = next.items.enumerated().map { index, item in + max(0, next.spacingBefore(index > 0 ? next.items[index - 1] : nil, item)) + } + let spacingChanged = configuredSpacing != spacing + let changed = next.items.filter { contentWidthChanged || metricsChanged || values[$0.id] != $0 }.map(\.id) let structureChanged = items.map(\.id) != next.items.map(\.id) let jump = next.jumpToken != lastJumpToken lastJumpToken = next.jumpToken @@ -348,7 +396,8 @@ public final class TranscriptCollectionController() snapshot.appendSections([0]) snapshot.appendItems(items.map(\.id)) diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Chat/TranscriptRowRole.swift b/ios/Packages/HapiKit/Sources/HapiUI/Chat/TranscriptRowRole.swift new file mode 100644 index 00000000..c8ef170f --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiUI/Chat/TranscriptRowRole.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Presentation-only rhythm; independent of protocol grouping and stable IDs. +public enum TranscriptRowRole { + case history, user, tool, content + + public func spacing(after previous: TranscriptRowRole?) -> CGFloat { + guard let previous, previous != .history, self != .history else { return 12 } + if self == .user { return previous == .user ? 8 : 24 } + if previous == .user { return 16 } + if self == .tool && previous == .tool { return 8 } + return 12 + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Code/CodeBlockView.swift b/ios/Packages/HapiKit/Sources/HapiUI/Code/CodeBlockView.swift index b09209af..eb81aa53 100644 --- a/ios/Packages/HapiKit/Sources/HapiUI/Code/CodeBlockView.swift +++ b/ios/Packages/HapiKit/Sources/HapiUI/Code/CodeBlockView.swift @@ -13,6 +13,7 @@ public struct CodeBlockView: View { public let code: String @Environment(\.hapiTheme) private var theme + @Environment(\.hapiTypography) private var typography @Environment(\.hapiSyntaxHighlighter) private var highlighter @Environment(\.hapiPasteboard) private var pasteboard @@ -39,7 +40,8 @@ public struct CodeBlockView: View { header ScrollView(.horizontal, showsIndicators: false) { Text(highlighted ?? AttributedString(code)) - .font(theme.codeFont) + .font(typography.codeFont) + .lineSpacing(typography.codeLineSpacing) .foregroundStyle(theme.textPrimary) .textSelection(.enabled) .padding(12) @@ -59,9 +61,9 @@ public struct CodeBlockView: View { private var header: some View { HStack(spacing: 8) { Text(displayLanguage) - .font(.system(size: theme.captionSize, design: .monospaced)) + .font(typography.captionMonoFont) .foregroundStyle(theme.textSecondary) - .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 12) Button { // Explicit MainActor hop: keeps the @MainActor pasteboard @@ -76,14 +78,14 @@ public struct CodeBlockView: View { } } label: { Image(systemName: showCopied ? "checkmark" : "doc.on.doc") - .font(.system(size: theme.captionSize)) + .font(typography.captionFont) + .frame(minWidth: 44, minHeight: 44) .foregroundStyle(showCopied ? theme.success : theme.textSecondary) } .buttonStyle(.plain) .accessibilityLabel(Text(showCopied ? "Copied" : "Copy code")) } .padding(.horizontal, 12) - .padding(.vertical, 6) .background(theme.codeHeaderBackground) } } diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffTextView.swift b/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffTextView.swift index 40f1350d..58380d7f 100644 --- a/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffTextView.swift +++ b/ios/Packages/HapiKit/Sources/HapiUI/Diff/DiffTextView.swift @@ -11,6 +11,7 @@ public struct DiffTextView: View { @State private var expanded = false @Environment(\.hapiTheme) private var theme + @Environment(\.hapiTypography) private var typography public init(files: [DiffFile], compact: Bool = false, compactLineLimit: Int = 12) { self.files = files @@ -101,10 +102,11 @@ public struct DiffTextView: View { expanded = true } label: { Text("Show all \(rows.count) lines") - .font(theme.captionFont) + .font(typography.captionFont) .foregroundStyle(theme.link) .padding(.horizontal, 12) .padding(.vertical, 8) + .frame(minHeight: 44) } .buttonStyle(.plain) } @@ -119,9 +121,9 @@ public struct DiffTextView: View { case .fileHeader(let file): HStack(spacing: 8) { Text(file.displayPath) - .font(.system(size: theme.captionSize, design: .monospaced)) + .font(typography.captionMonoFont) .foregroundStyle(theme.textSecondary) - .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) if file.kind == .renamed { badge("renamed", color: theme.warning) } @@ -137,14 +139,14 @@ public struct DiffTextView: View { .background(theme.codeHeaderBackground) case .hunkHeader(let header): Text(header) - .font(theme.codeFont) + .font(typography.codeFont) .foregroundStyle(theme.textSecondary) .padding(.horizontal, 12) .padding(.vertical, 3) .background(theme.hunkHeaderBackground) case .binaryNote: Text("Binary file not shown") - .font(theme.captionFont) + .font(typography.captionFont) .foregroundStyle(theme.textHint) .padding(.horizontal, 12) .padding(.vertical, 6) @@ -174,14 +176,15 @@ public struct DiffTextView: View { + " " ) text.foregroundColor = theme.textHint + text.font = typography.captionMonoFont var content = AttributedString(prefix + " " + line.text) content.foregroundColor = foreground text.append(content) return Text(text) - .font(theme.codeFont) + .font(typography.codeFont) .padding(.horizontal, 12) - .padding(.vertical, 1) + .padding(.vertical, typography.codeLineSpacing / 2) .background(background ?? Color.clear) } @@ -193,7 +196,7 @@ public struct DiffTextView: View { private func badge(_ label: String, color: Color) -> some View { Text(label) - .font(.system(size: theme.captionSize - 1, weight: .medium, design: .monospaced)) + .font(typography.captionMonoFont) .foregroundStyle(color) } } diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownRenderer.swift b/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownRenderer.swift index b27c67ea..5e89d91b 100644 --- a/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownRenderer.swift +++ b/ios/Packages/HapiKit/Sources/HapiUI/Markdown/MarkdownRenderer.swift @@ -463,12 +463,12 @@ struct InlineBuilder: MarkupVisitor { /// runs get the monospaced font + chip colors, link runs get the link color /// and underline. Bold/italic/strikethrough render natively from /// `InlinePresentationIntent`. -func hapiStyledText(_ source: AttributedString, theme: HapiTheme) -> AttributedString { +func hapiStyledText(_ source: AttributedString, theme: HapiTheme, typography: HapiTypography) -> AttributedString { var output = AttributedString() for run in source.runs { var piece = AttributedString(source[run.range]) if let intent = run.inlinePresentationIntent, intent.contains(.code) { - piece[AttributeScopes.SwiftUIAttributes.FontAttribute.self] = theme.inlineCodeFont + piece[AttributeScopes.SwiftUIAttributes.FontAttribute.self] = typography.inlineCodeFont piece[AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute.self] = theme.inlineCodeForeground piece[AttributeScopes.SwiftUIAttributes.BackgroundColorAttribute.self] = theme.inlineCodeBackground } @@ -514,28 +514,40 @@ struct MarkdownBlockListView: View { let blocks: [MarkdownBlockNode] var body: some View { - VStack(alignment: .leading, spacing: 10) { - ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in MarkdownBlockView(block: block) + .padding(.top, Self.spacing(before: block, after: index > 0 ? blocks[index - 1] : nil)) } } } + + static func spacing(before block: MarkdownBlockNode, after previous: MarkdownBlockNode?) -> CGFloat { + guard let previous else { return 0 } + if case .heading = block { return 24 } + if case .heading = previous { return 8 } + return 12 + } } struct MarkdownBlockView: View { let block: MarkdownBlockNode @Environment(\.hapiTheme) private var theme + @Environment(\.hapiTypography) private var typography var body: some View { switch block { case .paragraph(let text): - SwiftUI.Text(hapiStyledText(text, theme: theme)) - .font(theme.bodyFont) + SwiftUI.Text(hapiStyledText(text, theme: theme, typography: typography)) + .font(typography.bodyFont) + .lineSpacing(typography.bodyLineSpacing) + .fixedSize(horizontal: false, vertical: true) .foregroundStyle(theme.textPrimary) .textSelection(.enabled) case .heading(let level, let text): - SwiftUI.Text(hapiStyledText(text, theme: theme)) - .font(headingFont(level)) + SwiftUI.Text(hapiStyledText(text, theme: theme, typography: typography)) + .font(typography.headingFont(level)) + .fixedSize(horizontal: false, vertical: true) .foregroundStyle(theme.textPrimary) .textSelection(.enabled) case .codeBlock(let language, let code): @@ -547,6 +559,7 @@ struct MarkdownBlockView: View { .frame(width: 3) MarkdownBlockListView(blocks: children) } + .fixedSize(horizontal: false, vertical: true) .padding(10) .background(theme.quoteBackground) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) @@ -563,27 +576,15 @@ struct MarkdownBlockView: View { MarkdownImageRow(alt: alt, destination: destination) } } - - private func headingFont(_ level: Int) -> Font { - let size: CGFloat - switch level { - case 1: size = theme.bodySize + 6 - case 2: size = theme.bodySize + 4 - case 3: size = theme.bodySize + 2.5 - case 4: size = theme.bodySize + 1.5 - case 5: size = theme.bodySize + 1 - default: size = theme.bodySize + 0.5 - } - return .system(size: size, weight: .semibold) - } } struct MarkdownListView: View { let model: MarkdownListModel @Environment(\.hapiTheme) private var theme + @Environment(\.hapiTypography) private var typography var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { ForEach(Array(model.items.enumerated()), id: \.offset) { index, item in HStack(alignment: .firstTextBaseline, spacing: 8) { marker(for: item, index: index) @@ -597,16 +598,16 @@ struct MarkdownListView: View { private func marker(for item: MarkdownListModel.Item, index: Int) -> some View { if let checked = item.checkbox { SwiftUI.Image(systemName: checked ? "checkmark.square.fill" : "square") - .font(.system(size: theme.bodySize - 2)) + .font(.system(size: typography.bodySize - 2)) .foregroundStyle(checked ? theme.accent : theme.textHint) .accessibilityLabel(SwiftUI.Text(checked ? "Completed" : "Not completed")) } else if model.isOrdered { SwiftUI.Text("\(model.startIndex + index).") - .font(theme.bodyFont.monospacedDigit()) + .font(typography.bodyFont.monospacedDigit()) .foregroundStyle(theme.textHint) } else { SwiftUI.Text("•") - .font(theme.bodyFont) + .font(typography.bodyFont) .foregroundStyle(theme.textHint) } } @@ -615,15 +616,16 @@ struct MarkdownListView: View { struct MarkdownTableView: View { let model: MarkdownTableModel @Environment(\.hapiTheme) private var theme + @Environment(\.hapiTypography) private var typography var body: some View { ScrollView(.horizontal, showsIndicators: false) { Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) { GridRow { ForEach(Array(model.header.enumerated()), id: \.offset) { index, cell in - SwiftUI.Text(hapiStyledText(cell, theme: theme)) + SwiftUI.Text(hapiStyledText(cell, theme: theme, typography: typography)) .fontWeight(.semibold) - .font(theme.bodyFont) + .font(typography.bodyFont) .foregroundStyle(theme.textPrimary) .padding(.horizontal, 10) .padding(.vertical, 6) @@ -635,8 +637,8 @@ struct MarkdownTableView: View { Divider() GridRow { ForEach(Array(row.enumerated()), id: \.offset) { index, cell in - SwiftUI.Text(hapiStyledText(cell, theme: theme)) - .font(theme.bodyFont) + SwiftUI.Text(hapiStyledText(cell, theme: theme, typography: typography)) + .font(typography.bodyFont) .foregroundStyle(theme.textPrimary) .padding(.horizontal, 10) .padding(.vertical, 6) @@ -666,6 +668,7 @@ struct MarkdownImageRow: View { let alt: String let destination: String? @Environment(\.hapiTheme) private var theme + @Environment(\.hapiTypography) private var typography @Environment(\.hapiOpenURL) private var hapiOpenURL var body: some View { @@ -677,14 +680,14 @@ struct MarkdownImageRow: View { HStack(spacing: 6) { SwiftUI.Image(systemName: "photo") - .font(.system(size: theme.captionSize)) + .font(.system(size: typography.captionSize)) .foregroundStyle(theme.textHint) if let url { Button { hapiOpenURL(url) } label: { SwiftUI.Text(label) - .font(theme.captionFont) + .font(typography.captionFont) .foregroundStyle(theme.link) .underline() .lineLimit(1) @@ -692,7 +695,7 @@ struct MarkdownImageRow: View { .buttonStyle(.plain) } else { SwiftUI.Text(label) - .font(theme.captionFont) + .font(typography.captionFont) .foregroundStyle(theme.textSecondary) .lineLimit(1) } diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTheme.swift b/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTheme.swift index 3eda7b23..85872fe6 100644 --- a/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTheme.swift +++ b/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTheme.swift @@ -1,6 +1,6 @@ import SwiftUI -/// Semantic color + typography tokens for the HAPI rendering components. +/// Semantic color tokens for the HAPI rendering components. /// /// Three fixed palettes are provided: `.light`, `.dark`, and `.oled` /// (dark with pure-black backgrounds for OLED displays). The app injects @@ -55,22 +55,10 @@ public struct HapiTheme: Equatable, Sendable { public var diffRemovedForeground: Color public var hunkHeaderBackground: Color - // MARK: Typography scale - - public var bodySize: CGFloat - public var codeSize: CGFloat - public var captionSize: CGFloat - /// True for the `.dark` and `.oled` palettes (drives e.g. the /// highlight.js theme choice in `CodeBlockView`). public var isDark: Bool { variant != .light } - public var bodyFont: Font { .system(size: bodySize) } - public var codeFont: Font { .system(size: codeSize, design: .monospaced) } - public var captionFont: Font { .system(size: captionSize) } - /// Monospaced font for inline code runs inside body text. - public var inlineCodeFont: Font { .system(size: bodySize - 1.5, design: .monospaced) } - // MARK: - Palettes public static let light = HapiTheme( @@ -89,7 +77,7 @@ public struct HapiTheme: Equatable, Sendable { codeBackground: Color(hapiHex: 0xF6F8FA), codeHeaderBackground: Color(hapiHex: 0xEEF1F4), inlineCodeBackground: Color(hapiHex: 0xEEF1F4), - inlineCodeForeground: Color(hapiHex: 0xB01F63), + inlineCodeForeground: Color(hapiHex: 0x1C1D22), quoteBar: Color(hapiHex: 0xD1D5DB), quoteBackground: Color(hapiHex: 0xF8F9FB), tableHeaderBackground: Color(hapiHex: 0xF2F4F7), @@ -97,10 +85,7 @@ public struct HapiTheme: Equatable, Sendable { diffAddedForeground: Color(hapiHex: 0x116329), diffRemovedBackground: Color(hapiHex: 0xFFEBE9), diffRemovedForeground: Color(hapiHex: 0xA40E26), - hunkHeaderBackground: Color(hapiHex: 0xF1F8FF), - bodySize: 15, - codeSize: 13, - captionSize: 11 + hunkHeaderBackground: Color(hapiHex: 0xF1F8FF) ) public static let dark = HapiTheme( @@ -119,7 +104,7 @@ public struct HapiTheme: Equatable, Sendable { codeBackground: Color(hapiHex: 0x161B22), codeHeaderBackground: Color(hapiHex: 0x1F242D), inlineCodeBackground: Color(hapiHex: 0x262C36), - inlineCodeForeground: Color(hapiHex: 0xF585A8), + inlineCodeForeground: Color(hapiHex: 0xE8EAED), quoteBar: Color(hapiHex: 0x3B4048), quoteBackground: Color(hapiHex: 0x14171C), tableHeaderBackground: Color(hapiHex: 0x1C2028), @@ -127,10 +112,7 @@ public struct HapiTheme: Equatable, Sendable { diffAddedForeground: Color(hapiHex: 0x3FB950), diffRemovedBackground: Color(hapiHex: 0x2D1215), diffRemovedForeground: Color(hapiHex: 0xF85149), - hunkHeaderBackground: Color(hapiHex: 0x161F2E), - bodySize: 15, - codeSize: 13, - captionSize: 11 + hunkHeaderBackground: Color(hapiHex: 0x161F2E) ) /// Dark palette on pure-black backgrounds (OLED power saving). diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTypography.swift b/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTypography.swift new file mode 100644 index 00000000..8284da1f --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiUI/Theme/HapiTypography.swift @@ -0,0 +1,91 @@ +import SwiftUI + +/// Resolved once at the presentation root; never scale these values again. +/// Separate from color palettes so light, dark and OLED have identical metrics. +public struct HapiTypography: Equatable, Sendable { + public var bodyScale: CGFloat + public var codeScale: CGFloat + public var captionScale: CGFloat + public var boldText: Bool + + public init(bodyScale: CGFloat = 1, codeScale: CGFloat = 1, + captionScale: CGFloat = 1, boldText: Bool = false) { + self.bodyScale = bodyScale + self.codeScale = codeScale + self.captionScale = captionScale + self.boldText = boldText + } + + public var bodySize: CGFloat { 16 * bodyScale } + public var codeSize: CGFloat { 14 * codeScale } + public var captionSize: CGFloat { 12 * captionScale } + public var inlineCodeSize: CGFloat { 15 * bodyScale } + public var bodyLineSpacing: CGFloat { 3 * bodyScale } + public var codeLineSpacing: CGFloat { 2 * codeScale } + public var bodyFont: Font { .system(size: bodySize, weight: regularWeight) } + public var codeFont: Font { .system(size: codeSize, weight: regularWeight, design: .monospaced) } + public var captionFont: Font { .system(size: captionSize, weight: regularWeight) } + public var inlineCodeFont: Font { .system(size: inlineCodeSize, weight: regularWeight, design: .monospaced) } + public var captionMonoFont: Font { .system(size: captionSize, weight: regularWeight, design: .monospaced) } + + private var regularWeight: Font.Weight { boldText ? .semibold : .regular } + + public func headingSize(_ level: Int) -> CGFloat { + let base: CGFloat = switch level { + case 1: 24 + case 2: 20 + case 3: 18 + default: 17 + } + return base * bodyScale + } + + public func headingFont(_ level: Int) -> Font { + .system(size: headingSize(level), weight: boldText ? .bold : .semibold) + } +} + +private struct HapiTypographyKey: EnvironmentKey { + static let defaultValue = HapiTypography() +} + +public extension EnvironmentValues { + var hapiTypography: HapiTypography { + get { self[HapiTypographyKey.self] } + set { self[HapiTypographyKey.self] = newValue } + } +} + +private struct HapiTypographyModifier: ViewModifier { + @ScaledMetric(relativeTo: .body) private var bodyScale: CGFloat = 1 + @ScaledMetric(relativeTo: .footnote) private var codeScale: CGFloat = 1 + @ScaledMetric(relativeTo: .caption) private var captionScale: CGFloat = 1 + @Environment(\.legibilityWeight) private var legibilityWeight + + func body(content: Content) -> some View { + content.environment(\.hapiTypography, HapiTypography( + bodyScale: bodyScale, codeScale: codeScale, captionScale: captionScale, + boldText: legibilityWeight == .bold + )) + } +} + +public enum HapiReadingLayout { + public static let horizontalInset: CGFloat = 16 + public static let maximumWidth: CGFloat = 720 + + public static func contentWidth(in availableWidth: CGFloat) -> CGFloat { + max(1, min(maximumWidth, availableWidth - 2 * horizontalInset)) + } +} + +public extension View { + /// Install outside the chat list so hosted rows inherit resolved metrics. + func hapiTypography() -> some View { modifier(HapiTypographyModifier()) } + + func hapiReadingColumn(horizontalInset: CGFloat = HapiReadingLayout.horizontalInset) -> some View { + frame(maxWidth: HapiReadingLayout.maximumWidth, alignment: .leading) + .frame(maxWidth: .infinity) + .padding(.horizontal, horizontalInset) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiUITests/TypographyTests.swift b/ios/Packages/HapiKit/Tests/HapiUITests/TypographyTests.swift new file mode 100644 index 00000000..dc7ec366 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiUITests/TypographyTests.swift @@ -0,0 +1,68 @@ +import SwiftUI +import Testing +@testable import HapiUI + +@Suite("Reading typography") +struct TypographyTests { + @Test func baselineAndScaledMetricsKeepTheirHierarchy() { + let base = HapiTypography() + #expect(base.bodySize == 16) + #expect(base.codeSize == 14) + #expect(base.inlineCodeSize == 15) + #expect(base.captionSize == 12) + #expect((1...6).map(base.headingSize) == [24, 20, 18, 17, 17, 17]) + let large = HapiTypography(bodyScale: 3, codeScale: 2, captionScale: 2.5, boldText: true) + #expect(large.bodySize == 48) + #expect(large.inlineCodeSize == 45) + #expect(large.bodyLineSpacing == 9) + #expect(large.codeLineSpacing == 4) + #expect(large.headingSize(3) > large.bodySize) + } + + @Test func markdownMarginsAreBetweenBlocksNotStackedPadding() { + let paragraph = MarkdownBlockNode.paragraph(AttributedString("Body")) + let heading = MarkdownBlockNode.heading(level: 2, AttributedString("Heading")) + #expect(MarkdownBlockListView.spacing(before: heading, after: nil) == 0) + #expect(MarkdownBlockListView.spacing(before: paragraph, after: nil) == 0) + #expect(MarkdownBlockListView.spacing(before: heading, after: paragraph) == 24) + #expect(MarkdownBlockListView.spacing(before: heading, after: heading) == 24) + #expect(MarkdownBlockListView.spacing(before: paragraph, after: heading) == 8) + #expect(MarkdownBlockListView.spacing(before: paragraph, after: paragraph) == 12) + } + + @Test func conversationRhythmDoesNotMistakeHistoryForANewTurn() { + #expect(TranscriptRowRole.user.spacing(after: nil) == 12) + #expect(TranscriptRowRole.user.spacing(after: .history) == 12) + #expect(TranscriptRowRole.user.spacing(after: .content) == 24) + #expect(TranscriptRowRole.user.spacing(after: .user) == 8) + #expect(TranscriptRowRole.tool.spacing(after: .user) == 16) + #expect(TranscriptRowRole.content.spacing(after: .user) == 16) + #expect(TranscriptRowRole.tool.spacing(after: .tool) == 8) + #expect(TranscriptRowRole.content.spacing(after: .tool) == 12) + } + + @Test func readingWidthsFitSmallScreensAndCapTablets() { + #expect(HapiReadingLayout.contentWidth(in: 320) == 288) + #expect(HapiReadingLayout.contentWidth(in: 390) == 358) + #expect(HapiReadingLayout.contentWidth(in: 507) == 475) + #expect(HapiReadingLayout.contentWidth(in: 768) == 720) + #expect(HapiReadingLayout.contentWidth(in: 1024) == 720) + #expect(HapiReadingLayout.contentWidth(in: 0) == 1) + } + + @Test func inlineCodeIsNeutralButLinksStillWin() { + var source = AttributedString("code") + source.inlinePresentationIntent = .code + let typography = HapiTypography(bodyScale: 2) + for theme in [HapiTheme.light, .dark, .oled] { + let styled = hapiStyledText(source, theme: theme, typography: typography) + #expect(styled.font == typography.inlineCodeFont) + #expect(styled.foregroundColor == theme.textPrimary) + source.link = URL(string: "https://example.com") + let linked = hapiStyledText(source, theme: theme, typography: typography) + #expect(linked.foregroundColor == theme.link) + #expect(linked.underlineStyle == .single) + source.link = nil + } + } +} diff --git a/ios/README.md b/ios/README.md index 979af895..8f805b37 100644 --- a/ios/README.md +++ b/ios/README.md @@ -40,6 +40,32 @@ changes under `ios/**` and `shared/fixtures/**`). extraction stays disabled so opening or building the project does not rewrite the catalog with decorative or intentionally verbatim strings. +### Reading typography + +`HapiUI` separates color palettes (`HapiTheme`) from resolved Dynamic Type +metrics (`HapiTypography`). Install `.hapiTypography()` at a presentation root, +outside `AnchoredTranscriptList`; hosted rows inherit those metrics. Do not +scale the resolved values again. Body/user/composer text starts at 16pt, +inline code at 15pt, code/diffs/terminal at 14pt, and captions at 12pt. +Body and code add 3pt and 2pt of scaled inter-line spacing respectively. + +The transcript and composer share a centered, at-most-720pt reading column +with 16pt minimum side margins. Font, Bold Text, locale, and effective width +changes invalidate height measurements while preserving the reading anchor. +Ordinary streaming updates retain unchanged hosting roots and measurements. + +The UIKit transcript suite covers typography changes, recycling, shrinking +text, tablet/phone widths, and tail following. Optional deterministic visual +specimens cover light/dark/OLED, mixed Chinese/English, code, tables, diffs, +approvals, and the actual composer. They use a non-networked test interactor; +they are **not** live conversations or App Store screenshots. Capture into a +new temporary directory, never over the release gallery: + +```sh +TEST_RUNNER_HAPI_TYPOGRAPHY_CAPTURE=/tmp/hapi-typography-review \ + ios/scripts/test-transcript.sh -only-testing:HapiTests/TypographySnapshotTests +``` + ### Linux verification (no Mac needed) `ios/scripts/linux-test.sh` compiles and tests the non-UI targets