Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

### Design

- [`design/IOS_NATIVE_MOTION_REFINEMENT.md`](design/IOS_NATIVE_MOTION_REFINEMENT.md):EasyUI 概念映射、原生动效的局部采用范围与本次验证证据;共享约定仍由 Kitchen Design Language 的 Motion 节负责。

- [`design/UI_TASTE.md`](design/UI_TASTE.md):iOS 实质性视觉 refinement 的轻量判断层与 Taste Pass;不定义新 Design System。

### Product
Expand Down
88 changes: 88 additions & 0 deletions docs/design/IOS_NATIVE_MOTION_REFINEMENT.md

Large diffs are not rendered by default.

50 changes: 40 additions & 10 deletions docs/design/KITCHEN_DESIGN_LANGUAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,13 @@ The prior v1 text below is historical wherever it conflicts with this revision.
Motion is restrained and semantic, never decorative. Prefer SwiftUI system spring
presets over custom easing curves. `KitchenMotion` lives beside `KitchenTheme`:

| Token | Preset | Purpose |
| Intent / token | Preset | Purpose |
|---|---|---|
| quick | `snappy(duration: 0.2)` | Direct selections and small controls. |
| standard | `smooth(duration: 0.28)` | Content or local layout state transitions. |
| emphasis | `smooth(duration: 0.32, extraBounce: 0.05)` | Rare emphasis with a very light spring; unused in this spike. |
| Gentle / `standard` | `smooth(duration: 0.28)` | Content expansion and local layout transitions. |
| Snappy / `quick` | `snappy(duration: 0.2)` | Direct selections and small controls. |
| Morph / `morph` | Alias of `standard` | A continuous object's geometry change, only where needed. |
| Feedback / `feedback` | Alias of `quick` | Short, local loading/result label crossfades. |
| `emphasis` (reserved) | `smooth(duration: 0.32, extraBounce: 0.05)` | Rare emphasis; no new adoption in this refinement. |

Preserve system-native NavigationStack, sheet, tab, picker, toggle and other
platform transitions. Avoid broad implicit animations over large hierarchies;
Expand All @@ -47,15 +49,43 @@ when enabled. Preserve existing accessibility labels, traits and feedback.
State changes and actions remain immediate and interruptible; never wait for an
animation to finish before allowing interaction or completing a task.

This spike animates only the Accessibility-size Inventory Menu's current-filter
text with quick (the segmented Picker stays native), and Home's existing
remaining-dishes disclosure with standard. AI result appearance gets no extra
animation: the inspected recommendation flow already uses a native pager, and
initial Home content loading is not an entrance-animation opportunity.
The original D-043 adoption remains the Accessibility-size Inventory Menu's
current-filter text with quick (the segmented Picker stays native), and Home's
remaining-dishes disclosure with standard. D-048 subsequently replaced the
recommendation pager with the native view-aligned inline shelf. Initial loading
and result arrival remain free of entrance choreography.

The 2026-10-22 bounded refinement opts Home's secondary recommendation controls
into existing `homeTactile` feedback: the same quick press response as the lead
action, with scale and custom animation suppressed under Reduce Motion. It does
not change default shared-button behavior elsewhere, shelf geometry, snapping,
card identity, vertical Accessibility layout or regeneration ownership.

`KitchenActionLabel` is a presentation-only label inside an existing Button.
It reserves all phase labels' intrinsic footprint, adapts to Dynamic Type,
and uses `contentTransition(.opacity)` with Feedback for actual
Idle → Processing → Success / Failure changes. The caller owns real state,
disable rules, persistence, errors and navigation. Link-import save is the
first consumer. Its native error alert and existing success haptic remain;
do not add duplicate sensory feedback. Never add fake progress, timers,
minimum loading dwell or a success hold. Fast local saves may navigate before
the success label is perceptible; preserve that immediacy.

Use `matchedGeometryEffect` with Morph only for the same content object within
a stable namespace, when preserving its spatial identity helps the task.
No current shelf or button needs a new geometry animation. Preserve native
navigation/tab transitions. With Reduce Motion, disable custom transforms and
animated geometry; keep static labels, native progress, alerts and valid actions.
Hidden label-size reservations must be excluded from accessibility so VoiceOver
encounters one real control with its current text. Never communicate a result
through animation, color, sound or haptics alone. Dark appearance uses existing
semantic materials; no new palette or fixed-size text is introduced.

No whole-screen animation, appearance-only animation, staggered entrance,
exaggerated bounce, decorative scale or Dribbble-style motion. Existing motion
outside these two interactions is not globally migrated by this spike.
outside these explicitly adopted interactions is not globally migrated.

Audit, acceptance scope and current verification: [Native motion refinement](IOS_NATIVE_MOTION_REFINEMENT.md).

## Prior v1 specification — historical where superseded above

Expand Down
22 changes: 13 additions & 9 deletions ios-native/Kitchen Manager/KitchenManager/AddRecipeViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -639,15 +639,12 @@ struct ImportRecipeView: View {
} label: {
HStack {
Spacer()
if isSaving {
ProgressView()
.tint(AppTheme.onManagementAction)
} else {
Label(
isSaved ? "已保存" : "保存到菜谱库",
systemImage: isSaved ? "checkmark.circle.fill" : "square.and.arrow.down"
)
}
KitchenActionLabel(
phase: savePhase,
idleTitle: "保存到菜谱库",
idleSymbol: "square.and.arrow.down"
)
.tint(AppTheme.onManagementAction)
Spacer()
}
}
Expand Down Expand Up @@ -718,6 +715,13 @@ struct ImportRecipeView: View {
)
}

private var savePhase: KitchenActionPhase {
if isSaving { return .processing }
if isSaved { return .success }
if saveErrorMessage != nil { return .failure }
return .idle
}

private var canSave: Bool {
guard let editableDraft else { return false }
return editableDraft.isSaveEligible
Expand Down
5 changes: 4 additions & 1 deletion ios-native/Kitchen Manager/KitchenManager/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ struct KitchenManagerApp: App {
|| ProcessInfo.processInfo.arguments.contains("UITEST_AI_CONVERSATION_FAKE")
|| AIConversationAcceptanceFixture.scenario != nil
let isolatedFixture = RecipeRegressionFixture.isEnabled || ShoppingRegressionFixture.isEnabled
|| KitchenMotionImportFixture.isEnabled
|| PlannerRegressionFixture.isEnabled || PlanPersistenceFailureFixture.failingWriteIndex != nil
|| isConversationUITest
var persistence = isolatedFixture ? KitchenPersistenceFactory.isolatedInMemory() : KitchenPersistenceFactory.application()
Expand Down Expand Up @@ -534,7 +535,9 @@ struct KitchenManagerApp: App {
WindowGroup {
Group {
#if DEBUG
if PlannerRegressionFixture.isEnabled {
if KitchenMotionImportFixture.isEnabled {
KitchenMotionImportFixture()
} else if PlannerRegressionFixture.isEnabled {
PlannerRegressionHost()
} else if AIRecipeRegenerationHost.isEnabled {
AIRecipeRegenerationHost()
Expand Down
9 changes: 5 additions & 4 deletions ios-native/Kitchen Manager/KitchenManager/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1552,17 +1552,18 @@ private struct HomeRecommendationSection: View {
Label(added ? "已加入" : "加入今天", systemImage: added ? "checkmark" : "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(KitchenButtonStyle(role: .secondary))
.buttonStyle(KitchenButtonStyle(role: .secondary, homeTactile: true))
.disabled(added)
.accessibilityIdentifier("home.recommendation.\(recommendation.id).addToday")

Button {
onViewRecipe(recipe)
} label: {
Text("查看菜谱")
.frame(maxWidth: .infinity)
.frame(maxWidth: .infinity, minHeight: KitchenTheme.controlHeight)
.contentShape(Rectangle())
}
.buttonStyle(KitchenButtonStyle(role: .utility, tint: KitchenTheme.cookingGreen))
.buttonStyle(KitchenButtonStyle(role: .utility, tint: KitchenTheme.cookingGreen, homeTactile: true))
.accessibilityIdentifier("home.recommendation.\(recommendation.id).viewRecipe")
}

Expand Down Expand Up @@ -1597,7 +1598,7 @@ private struct HomeRecommendationSection: View {
Label(isGenerating ? "正在换一批…" : "换一批", systemImage: KitchenAISymbol.emblem)
.frame(maxWidth: .infinity, minHeight: AppTheme.minimumHitTarget)
}
.buttonStyle(KitchenButtonStyle(role: .secondary))
.buttonStyle(KitchenButtonStyle(role: .secondary, homeTactile: true))
.disabled(isGenerating)
.accessibilityIdentifier("home.recommendation.regenerate")
}
Expand Down
66 changes: 66 additions & 0 deletions ios-native/Kitchen Manager/KitchenManager/KitchenActionLabel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import SwiftUI

enum KitchenActionPhase: CaseIterable {
case idle, processing, success, failure
}

/// Presentation only. The caller owns the operation, disable rules, errors and
/// navigation. Reserve all four labels' footprint without imposing a fixed
/// height, so Dynamic Type and state changes cannot move the button's edges.
struct KitchenActionLabel: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let phase: KitchenActionPhase
let idleTitle: String
let idleSymbol: String
var processingTitle = "正在保存"
var successTitle = "已保存"
var failureTitle = "重试保存"

var body: some View {
ZStack {
ForEach(KitchenActionPhase.allCases, id: \.self) { candidate in
Label(title(for: candidate), systemImage: symbol(for: candidate))
.hidden()
.accessibilityHidden(true)
}
}
.overlay {
Label {
Text(title(for: phase))
.contentTransition(.opacity)
} icon: {
if phase == .processing {
ProgressView()
.controlSize(.small)
.transition(.opacity)
} else {
Image(systemName: symbol(for: phase))
.contentTransition(.opacity)
}
}
.animation(reduceMotion ? nil : KitchenMotion.feedback, value: phase)
.accessibilityHidden(true)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(title(for: phase)))
.accessibilityValue(phase == .failure ? Text("保存失败") : Text(""))
}

private func title(for phase: KitchenActionPhase) -> String {
switch phase {
case .idle: idleTitle
case .processing: processingTitle
case .success: successTitle
case .failure: failureTitle
}
}

private func symbol(for phase: KitchenActionPhase) -> String {
switch phase {
case .idle: idleSymbol
case .processing: "arrow.trianglehead.2.clockwise"
case .success: "checkmark.circle.fill"
case .failure: "exclamationmark.circle"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#if DEBUG
import SwiftUI

/// Isolated UI-test host. It exercises the real import save operation and
/// keeps its result visible using the view's existing onSaved callback.
/// Normal launches never enter this host or replace application persistence.
@MainActor
struct KitchenMotionImportFixture: View {
static var isEnabled: Bool {
ProcessInfo.processInfo.arguments.contains("UITEST_MOTION_IMPORT")
|| ProcessInfo.processInfo.arguments.contains("UITEST_MOTION_LABELS")
}

@StateObject private var store: RecipeStore
@Environment(\.accessibilityReduceMotion) private var reduceMotion

init() {
let bundle = KitchenPersistenceFactory.isolatedInMemory()
let persistence = FailOneWrite(wrapping: bundle.userRecipes)
let suite = "ui-motion-import-\(ProcessInfo.processInfo.processIdentifier)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
let store = RecipeStore(userDefaults: defaults,
userRecipePersistence: persistence,
recipePreferencePersistence: bundle.recipePreferences)
// Arm after initialization, so migration/loads remain ordinary reads.
persistence.failNextWrite = ProcessInfo.processInfo.arguments.contains("UITEST_MOTION_FAIL_FIRST_SAVE")
defaults.removePersistentDomain(forName: suite)
_store = StateObject(wrappedValue: store)
}

var body: some View {
NavigationStack {
if ProcessInfo.processInfo.arguments.contains("UITEST_MOTION_LABELS") {
phaseGallery
} else {
ImportRecipeView(onSaved: {})
}
}
.environmentObject(store)
.overlay(alignment: .topLeading) {
Color.clear.frame(width: 1, height: 1)
.accessibilityElement()
.accessibilityLabel("Motion environment")
.accessibilityValue(reduceMotion && UIAccessibility.isReduceMotionEnabled ? "reduced" : "standard")
.accessibilityIdentifier("motion.environment")
}
}

private var phaseGallery: some View {
ScrollView {
VStack(spacing: 20) {
Text("组件状态预览(非保存操作)").font(.caption)
ForEach(KitchenActionPhase.allCases, id: \.self) { phase in
Button {} label: {
HStack {
Spacer()
KitchenActionLabel(phase: phase, idleTitle: "保存到菜谱库", idleSymbol: "square.and.arrow.down")
.tint(AppTheme.onManagementAction)
Spacer()
}
}
.buttonStyle(.borderedProminent)
.tint(AppTheme.managementActionFill)
.foregroundStyle(AppTheme.onManagementAction)
.accessibilityIdentifier("motion.preview.\(phase)")
}
}
.padding(20)
}
.background(KitchenTheme.canvas)
.navigationTitle("组件状态预览")
.navigationBarTitleDisplayMode(.inline)
}

private final class FailOneWrite: UserRecipePersistenceProtocol {
let wrapped: any UserRecipePersistenceProtocol
var failNextWrite = false
init(wrapping wrapped: any UserRecipePersistenceProtocol) { self.wrapped = wrapped }
func loadRecipes() throws -> [Recipe] { try wrapped.loadRecipes() }
func storedRecordCount() throws -> Int { try wrapped.storedRecordCount() }
func deleteAll() throws { try wrapped.deleteAll() }
func replaceRecipes(with recipes: [Recipe]) throws {
if failNextWrite {
failNextWrite = false
throw CocoaError(.fileWriteUnknown)
}
try wrapped.replaceRecipes(with: recipes)
}
}
}
#endif
3 changes: 3 additions & 0 deletions ios-native/Kitchen Manager/KitchenManager/KitchenTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ enum KitchenMotion {
static let quick: Animation = .snappy(duration: 0.2)
static let standard: Animation = .smooth(duration: 0.28)
static let emphasis: Animation = .smooth(duration: 0.32, extraBounce: 0.05)
// Semantic aliases reuse the existing curves; no second motion system.
static let morph = standard
static let feedback = quick
}

/// The approved Kitchen Manager visual language for Home and Inventory:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import SwiftUI
import XCTest
@testable import KitchenManager

@MainActor
final class KitchenActionLabelTests: XCTestCase {
func testSaveLabelKeepsItsFootprintAcrossEveryPhaseAndTextSize() {
for size in [DynamicTypeSize.large, .accessibility5] {
let dimensions = KitchenActionPhase.allCases.map { phase in
let host = UIHostingController(rootView:
KitchenActionLabel(phase: phase, idleTitle: "保存到菜谱库", idleSymbol: "square.and.arrow.down")
.environment(\.dynamicTypeSize, size)
)
return host.sizeThatFits(in: CGSize(width: 300, height: 1_000))
}
for dimension in dimensions {
XCTAssertEqual(dimension.width, dimensions[0].width, accuracy: 0.5)
XCTAssertEqual(dimension.height, dimensions[0].height, accuracy: 0.5)
XCTAssertGreaterThan(dimension.height, 0)
}
}
}

func testEveryPhaseRendersDistinctVisibleContent() throws {
var images: [Data] = []
for phase in KitchenActionPhase.allCases {
let image = try snapshot(
KitchenActionLabel(phase: phase, idleTitle: "保存到菜谱库", idleSymbol: "square.and.arrow.down")
.foregroundStyle(.white)
.padding(16)
.background(.blue)
.environment(\.colorScheme, .light)
)
images.append(try XCTUnwrap(image.pngData()))
}
// Geometry-only and accessibility-label tests would stay green if the
// visible overlay were accidentally deleted. Pixel output must differ.
XCTAssertEqual(Set(images).count, KitchenActionPhase.allCases.count)
}

private func snapshot<Content: View>(_ content: Content) throws -> UIImage {
let host = UIHostingController(rootView: content)
let size = host.sizeThatFits(in: CGSize(width: 390, height: 2_000))
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.frame = CGRect(origin: .zero, size: size)
window.rootViewController = host
window.isHidden = false
defer { window.isHidden = true }
host.view.frame = window.bounds
host.view.layoutIfNeeded()
// SwiftUI ImageRenderer substitutes an unavailable glyph for the native
// ProgressView. Render the hosted hierarchy to include the real spinner.
return UIGraphicsImageRenderer(size: size).image { _ in
host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true)
}
}
}
Loading
Loading