feat(keyboard): harden polish/clipboard guards and ship 1.6.6 (build 59)

Keep marketing version at 1.6.6 and bump build to 59. Strengthen never-answer
polish safeguards, clipboard reply-intent continuity, voice undo UX, device
UITests harness, eval fixtures, and What's New assets.
This commit is contained in:
Rocky
2026-08-09 12:45:33 +08:00
parent bfe2cd2001
commit 5d7fcdf24e
41 changed files with 2876 additions and 464 deletions
+75 -9
View File
@@ -11,8 +11,8 @@
// [OSG] EN header band (top)
// (transcript preview)
//
// mic (centred) action cluster:
// [delete] [ return ] [space] mic + bottom row
// mic (centred) action cluster:
// [delete] [ return ] [space] mic + undo + bottom row
//
//
@@ -22,6 +22,8 @@ import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let micSize: CGFloat = 121
static let micToButtonGap: CGFloat = 8
/// Square undo key beside the mic (outer edge, aligned with delete).
static let undoButtonSize: CGFloat = 44
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
/// Gap between the top control row and the transcript / hint line.
@@ -49,6 +51,27 @@ private enum KeyboardLayoutMetrics {
/// The shared 4 pt outer padding is the complete bottom inset.
static let actionClusterBottomGap: CGFloat = 0
/// `RecordButton` draws its outer ring at 106 pt inside the 121 pt touch
/// frame, so the disc's *visible* top edge sits below the frame's top.
static let micRingInset: CGFloat = (micSize - 106) / 2
/// //EN capsule: 30 pt buttons + 2 pt padding, centred in the top bar.
static let topBarTabCapsuleHeight: CGFloat = 34
/// Pushes the transcript / hint line down to the vertical centre of the gap
/// between the tab capsule's bottom edge and the mic's visible top edge.
/// Applied as an offset so the band heights and therefore `totalHeight`
/// and `micUpwardAdjustment` stay untouched.
static var transcriptLineDownwardAdjustment: CGFloat {
let capsuleBottom = (topBarHeight + topBarTabCapsuleHeight) / 2
let micVisibleTop = topBarHeight
+ topBarToTranscriptSpacing
+ transcriptLineHeight
+ actionClusterTopGap
- micUpwardAdjustment
+ micRingInset
let currentCentre = topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight / 2
return (capsuleBottom + micVisibleTop) / 2 - currentCentre
}
static var headerBandHeight: CGFloat {
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
}
@@ -138,6 +161,7 @@ public struct KeyboardRootView: View {
openSettings: state.openSettings
)
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
.offset(y: KeyboardLayoutMetrics.transcriptLineDownwardAdjustment)
}
}
@@ -164,7 +188,8 @@ public struct KeyboardRootView: View {
/// Mic centred above a bottom row: delete · smart return · space (or swapped).
/// The side cursor-drag pads are SwiftUI layout wrappers around UIKit
/// pan recognizers, avoiding SwiftUI gesture delivery issues in
/// keyboard extensions.
/// keyboard extensions. A square undo key sits on the outer pad,
/// vertically centred with the mic and mirrored with handedness.
private var micActionRow: some View {
let editingBlocked = voiceInputBlocksEditing
let swapKeys = state.handednessPreference.swapsActionKeys
@@ -174,6 +199,9 @@ public struct KeyboardRootView: View {
// opacity so the pads' hit area never shifts mid-gesture) and lets
// the cursor-drag chrome take over.
let dragging = state.cursorDragActive
let clipboardRecording = state.clipboardCommandRecording
// Undo hides during drag (like mic) and during clipboard side captions.
let undoVisible = !dragging && !clipboardRecording
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
HStack(spacing: 0) {
@@ -181,9 +209,18 @@ public struct KeyboardRootView: View {
.overlay {
clipboardSideHint(
ExtL10n.text("keyboard.clipboard.recordingLeft"),
visible: state.clipboardCommandRecording && !dragging
visible: clipboardRecording && !dragging
)
}
.overlay(alignment: .leading) {
// Left-handed: undo shares the outer edge with delete.
if !swapKeys {
undoButton(
disabled: editingBlocked || !state.undoAvailable,
visible: undoVisible
)
}
}
RecordButton(
phase: buttonPhase,
@@ -206,9 +243,18 @@ public struct KeyboardRootView: View {
.overlay {
clipboardSideHint(
ExtL10n.text("keyboard.clipboard.recordingRight"),
visible: state.clipboardCommandRecording && !dragging
visible: clipboardRecording && !dragging
)
}
.overlay(alignment: .trailing) {
// Right-handed: undo mirrors to the outer (delete) side.
if swapKeys {
undoButton(
disabled: editingBlocked || !state.undoAvailable,
visible: undoVisible
)
}
}
}
.frame(height: KeyboardLayoutMetrics.micSize)
.animation(.easeInOut(duration: 0.25), value: state.clipboardCommandRecording)
@@ -281,6 +327,27 @@ public struct KeyboardRootView: View {
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
/// Square undo key on the outer drag pad same chrome / haptic / click
/// as space & return. Vertically matches the mic disc.
private func undoButton(disabled: Bool, visible: Bool) -> some View {
RectangularToolbarButton(
systemName: "arrow.uturn.backward",
label: ExtL10n.string("keyboard.undoA11y"),
disabled: disabled,
hapticIntensity: state.keyboardHapticIntensity
) {
state.undoLastInsertion()
}
.frame(
width: KeyboardLayoutMetrics.undoButtonSize,
height: KeyboardLayoutMetrics.undoButtonSize
)
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
.opacity(visible ? 1 : 0)
.allowsHitTesting(visible && !disabled)
.accessibilityHidden(!visible)
}
private func bottomSpaceButton(disabled: Bool) -> some View {
RectangularToolbarButton(
spaceStyle: true,
@@ -319,8 +386,8 @@ public struct KeyboardRootView: View {
private var buttonPhase: RecordButton.Phase {
switch clipboardMicChrome {
case .preparingDisabled:
return .processing
case .preparingCancelable:
return .preparing
case .recordingBlue:
return .recording
case .none:
@@ -338,10 +405,9 @@ public struct KeyboardRootView: View {
}
}
/// Preparing clipboard capture: grey spinner, not tappable.
/// Preparing clipboard capture: grey spinner, tap to cancel.
private var micButtonEnabled: Bool {
if state.micDisabled { return false }
if clipboardMicChrome == .preparingDisabled { return false }
return true
}