From b8ea0a1612373f531668582c54459d96624a0b95 Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 01:26:51 +0900 Subject: [PATCH 01/14] docs: add design system specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines Muxi's visual identity — Warm/Friendly personality with Lavender accent, semantic token system, spacing/typography/motion scales. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-03-design-system.md | 199 +++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/plans/2026-03-03-design-system.md diff --git a/docs/plans/2026-03-03-design-system.md b/docs/plans/2026-03-03-design-system.md new file mode 100644 index 0000000..1ec3c7d --- /dev/null +++ b/docs/plans/2026-03-03-design-system.md @@ -0,0 +1,199 @@ +# Muxi Design System Specification + +**Date**: 2026-03-03 +**Status**: Approved + +## Design Direction + +| Decision | Choice | +|----------|--------| +| Personality | Warm/Friendly | +| Accent Color | Lavender/Purple | +| Mode | Dark Only | +| Background Tone | Warm Dark (purple undertone) | +| Navigation | Full Immersive (terminal fullscreen, management UI as overlay) | +| Controls | Soft Cards (rounded corners, subtle bg difference, no shadows) | +| Motion | Playful (spring bounce, accessibilityReduceMotion respected) | +| Design Approach | Semantic Token System | + +--- + +## 1. Color System + +### 60-30-10 Ratio + +**Terminal Screen (90% of usage time):** + +| Ratio | Role | Color | +|-------|------|-------| +| 95% | Terminal theme background | Per-theme (default Catppuccin #1E1E2E) | +| 4% | Extended keyboard / status | Surface Raised | +| 1% | Accent (cursor, active tab) | Lavender | + +**Management UI (server list, settings — overlay):** + +| Ratio | Role | Color | +|-------|------|-------| +| 60% | Background / surface | Surface / Surface Raised | +| 30% | Text / icons | Text Primary / Secondary | +| 10% | Accent / status | Lavender + Semantic | + +### Color Tokens + +``` +── Surface (background layers) ───────────── +surface.base #120E18 Deepest background (app root) +surface.default #1A1520 Default background +surface.raised #241E2C Cards, overlays, sheets +surface.elevated #2E2738 Modals, popovers (topmost) + +── Accent (Lavender) ─────────────────────── +accent.default #B5A8D5 Buttons, toggles, active state +accent.bright #D4C8F0 Hover/focus highlight +accent.subtle #B5A8D5 @ 12% Background tint, selected state +accent.muted #B5A8D5 @ 6% Very subtle hint + +── Text ──────────────────────────────────── +text.primary #EAE2F2 Primary text (contrast 12:1) +text.secondary #9B90A8 Secondary text, captions +text.tertiary #6B6178 Inactive, placeholders +text.inverse #120E18 Text on accent backgrounds + +── Border / Divider ──────────────────────── +border.default #FFFFFF @ 8% Subtle separators +border.strong #FFFFFF @ 15% Card borders +border.accent #B5A8D5 @ 30% Focus rings, selection borders + +── Semantic (status) ─────────────────────── +semantic.error #FF6B6B Error, delete +semantic.success #6BCB77 Connected, complete +semantic.warning #FFD93D Warning, caution +semantic.info #74B9FF Info, links +``` + +### Design Rationale + +- Surface layers have ~6-8% lightness difference (OKLCH L) between each step +- All surfaces carry purple undertone (Hue ~280°) for warmth +- Semantic colors match Lavender in lightness but differ in hue and saturation + +--- + +## 2. Typography Scale + +``` +── Non-terminal UI (SF Pro / System) ─────── +font.largeTitle .title2 semibold Overlay titles +font.title .headline semibold Section titles, server names +font.body .body regular General text +font.caption .caption regular Secondary info, timestamps +font.label .footnote medium Button labels, badges + +── Terminal ──────────────────────────────── +font.terminal Sarasa Mono SC NF 14pt Terminal content +``` + +Non-terminal UI uses SF Pro with Dynamic Type for accessibility. + +--- + +## 3. Spacing System (4pt grid) + +``` +spacing.xs 4pt Inline element gaps +spacing.sm 8pt Icon-text, tight grouping +spacing.md 12pt Card internal padding +spacing.lg 16pt Section gaps, card external margin +spacing.xl 24pt Major section separation +spacing.xxl 32pt Overlay top/bottom padding +``` + +--- + +## 4. Corner Radius + +``` +radius.sm 8pt Buttons, small badges, inputs +radius.md 12pt Cards, list rows +radius.lg 16pt Sheets, overlays +radius.full 9999pt Circles (avatars, FAB) +``` + +Principle: No sharp corners (0pt). Minimum 8pt. Rounder = friendlier. + +--- + +## 5. Motion System + +``` +── Animation Curves ──────────────────────── +motion.appear .spring(duration: 0.4, bounce: 0.15) + Card/overlay entrance. Slight bounce. + +motion.tap .spring(duration: 0.2, bounce: 0.2) + Button tap feedback. Fast and elastic. + +motion.transition .spring(duration: 0.35, bounce: 0.1) + Screen transitions. Smooth and natural. + +motion.subtle .easeInOut(duration: 0.2) + Color changes, opacity transitions. + +── Reduce Motion ─────────────────────────── +@Environment(\.accessibilityReduceMotion) +→ When true: replace all spring with .easeInOut(0.2), + remove bounce, reduce duration +``` + +Bounce range 0.15-0.2: friendly but trustworthy. + +--- + +## 6. Component Patterns + +### Server Card (inside overlay) + +``` +╭─────────────────────────────────╮ surface.raised +│ ● production-01 SSH ↗ │ radius.md (12pt) +│ 192.168.1.1 2시간 전 │ padding: spacing.md (12pt) +╰─────────────────────────────────╯ + ● = semantic.success + Server name = text.primary, font.title + IP/time = text.secondary, font.caption + SSH badge = accent.subtle bg + accent.default text +``` + +### Extended Keyboard (terminal bottom) + +``` +┌─────────────────────────────────┐ surface.raised +│ [Ctrl] [Alt] [Esc] │ [↑][↓][←][→]│ height: 44pt +└─────────────────────────────────┘ key button: radius.sm (8pt) + Active modifier = accent.default background + Inactive = accent.subtle background + Divider = border.default +``` + +### Overlay Sheet (management UI) + +``` +╭─────────────────────────────────╮ surface.elevated +│ ─── (handle) │ radius.lg (16pt) +│ │ motion.appear entrance +│ Servers [+] │ padding: spacing.xxl top +│ │ padding: spacing.lg horizontal +│ ╭ Server Card ─────────────╮ │ +│ ╰──────────────────────────╯ │ +│ ╭ Server Card ─────────────╮ │ +│ ╰──────────────────────────╯ │ +╰─────────────────────────────────╯ + handle = border.strong, radius.full + [+] button = accent.default +``` + +### Rules + +- Never use semantic + accent colors on the same component for different purposes +- Status dot = semantic color, badge = accent color (role separation) +- Cards never have drop shadows — depth expressed through surface lightness only From 80be0e1baae85df00680200d9a59d490b65f3431 Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 01:31:58 +0900 Subject: [PATCH 02/14] docs: add design system implementation plan 10-task plan: create MuxiTokens (colors, spacing, radius, typography, motion) and migrate all hardcoded values across SwiftUI views. Co-Authored-By: Claude Opus 4.6 --- ...2026-03-03-design-system-implementation.md | 597 ++++++++++++++++++ 1 file changed, 597 insertions(+) create mode 100644 docs/plans/2026-03-03-design-system-implementation.md diff --git a/docs/plans/2026-03-03-design-system-implementation.md b/docs/plans/2026-03-03-design-system-implementation.md new file mode 100644 index 0000000..6e9bd73 --- /dev/null +++ b/docs/plans/2026-03-03-design-system-implementation.md @@ -0,0 +1,597 @@ +# Design System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Introduce a semantic design token system (colors, spacing, radius, typography, motion) and migrate all hardcoded values across Muxi's SwiftUI views. + +**Architecture:** A single `MuxiTokens` enum (no instances) provides all design tokens as static properties. Views replace hardcoded values with token references. Motion helpers respect `accessibilityReduceMotion`. Terminal rendering (Metal/Theme.swift) is untouched — tokens apply only to the non-terminal SwiftUI UI layer. + +**Tech Stack:** SwiftUI, Swift Testing, iOS 17+ + +**Reference:** `docs/plans/2026-03-03-design-system.md` (approved design spec) + +--- + +### Task 1: Create MuxiTokens — Color Tokens + +**Files:** +- Create: `ios/Muxi/DesignSystem/MuxiTokens.swift` +- Test: `ios/Muxi/Tests/DesignTokenTests.swift` + +**Step 1: Write the failing test** + +```swift +import Testing +@testable import Muxi + +@Suite("Design Tokens — Colors") +struct ColorTokenTests { + @Test func surfaceLayersHaveIncreasingLightness() { + // Surface layers should get progressively lighter + let surfaces = [ + MuxiTokens.Colors.surfaceBase, + MuxiTokens.Colors.surfaceDefault, + MuxiTokens.Colors.surfaceRaised, + MuxiTokens.Colors.surfaceElevated + ] + for i in 0.. bCurrent, "Surface layer \(i+1) should be lighter than \(i)") + } + } + + @Test func accentColorIsDefined() { + let accent = MuxiTokens.Colors.accentDefault + let (r, g, b) = accent.rgbComponents + // Lavender: approximately #B5A8D5 + #expect(r > 0.6 && r < 0.8) + #expect(g > 0.5 && g < 0.75) + #expect(b > 0.75 && b < 0.95) + } + + @Test func semanticColorsAreDefined() { + // Semantic colors must exist and be distinct from accent + _ = MuxiTokens.Colors.error + _ = MuxiTokens.Colors.success + _ = MuxiTokens.Colors.warning + _ = MuxiTokens.Colors.info + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `xcodebuild test -project ios/Muxi.xcodeproj -scheme Muxi -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' CODE_SIGNING_ALLOWED=NO -only-testing:MuxiTests/ColorTokenTests 2>&1 | tail -20` +Expected: FAIL — `MuxiTokens` not defined + +**Step 3: Write minimal implementation** + +Create `ios/Muxi/DesignSystem/MuxiTokens.swift`: + +```swift +import SwiftUI + +// MARK: - Design Tokens + +/// Muxi semantic design token system. +/// All visual constants (colors, spacing, radii, typography, motion) live here. +/// Views reference tokens by role, never by raw value. +enum MuxiTokens { + + // MARK: - Colors + + enum Colors { + // Surface (background layers) — purple undertone, ~6% lightness steps + static let surfaceBase = Color(red: 0.071, green: 0.055, blue: 0.094) // #120E18 + static let surfaceDefault = Color(red: 0.102, green: 0.082, blue: 0.125) // #1A1520 + static let surfaceRaised = Color(red: 0.141, green: 0.118, blue: 0.173) // #241E2C + static let surfaceElevated = Color(red: 0.180, green: 0.153, blue: 0.220) // #2E2738 + + // Accent (Lavender) + static let accentDefault = Color(red: 0.710, green: 0.659, blue: 0.835) // #B5A8D5 + static let accentBright = Color(red: 0.831, green: 0.784, blue: 0.941) // #D4C8F0 + static let accentSubtle = accentDefault.opacity(0.12) + static let accentMuted = accentDefault.opacity(0.06) + + // Text + static let textPrimary = Color(red: 0.918, green: 0.878, blue: 0.949) // #EAE0F2 + static let textSecondary = Color(red: 0.608, green: 0.565, blue: 0.659) // #9B90A8 + static let textTertiary = Color(red: 0.420, green: 0.380, blue: 0.471) // #6B6178 + static let textInverse = surfaceBase + + // Border / Divider + static let borderDefault = Color.white.opacity(0.08) + static let borderStrong = Color.white.opacity(0.15) + static let borderAccent = accentDefault.opacity(0.30) + + // Semantic (status) + static let error = Color(red: 1.000, green: 0.420, blue: 0.420) // #FF6B6B + static let success = Color(red: 0.420, green: 0.796, blue: 0.467) // #6BCB77 + static let warning = Color(red: 1.000, green: 0.851, blue: 0.239) // #FFD93D + static let info = Color(red: 0.455, green: 0.725, blue: 1.000) // #74B9FF + } +} + +// MARK: - Color Helpers + +extension Color { + /// Extract approximate RGB components (for testing) + var rgbComponents: (red: CGFloat, green: CGFloat, blue: CGFloat) { + let uiColor = UIColor(self) + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + uiColor.getRed(&r, green: &g, blue: &b, alpha: &a) + return (r, g, b) + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `xcodebuild test -project ios/Muxi.xcodeproj -scheme Muxi -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' CODE_SIGNING_ALLOWED=NO -only-testing:MuxiTests/ColorTokenTests 2>&1 | tail -20` +Expected: PASS + +**Step 5: Commit** + +```bash +git add ios/Muxi/DesignSystem/MuxiTokens.swift ios/Muxi/Tests/DesignTokenTests.swift +git commit -m "feat: add MuxiTokens color definitions with tests" +``` + +--- + +### Task 2: Add Spacing, Radius, and Typography Tokens + +**Files:** +- Modify: `ios/Muxi/DesignSystem/MuxiTokens.swift` +- Modify: `ios/Muxi/Tests/DesignTokenTests.swift` + +**Step 1: Write the failing test** + +Append to `DesignTokenTests.swift`: + +```swift +@Suite("Design Tokens — Spacing") +struct SpacingTokenTests { + @Test func allSpacingsAreMultiplesOf4() { + let spacings: [CGFloat] = [ + MuxiTokens.Spacing.xs, + MuxiTokens.Spacing.sm, + MuxiTokens.Spacing.md, + MuxiTokens.Spacing.lg, + MuxiTokens.Spacing.xl, + MuxiTokens.Spacing.xxl + ] + for spacing in spacings { + #expect(spacing.truncatingRemainder(dividingBy: 4) == 0, + "\(spacing) is not a multiple of 4") + } + } + + @Test func spacingsAreStrictlyIncreasing() { + let spacings: [CGFloat] = [ + MuxiTokens.Spacing.xs, + MuxiTokens.Spacing.sm, + MuxiTokens.Spacing.md, + MuxiTokens.Spacing.lg, + MuxiTokens.Spacing.xl, + MuxiTokens.Spacing.xxl + ] + for i in 0..= 8) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `xcodebuild test -project ios/Muxi.xcodeproj -scheme Muxi -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' CODE_SIGNING_ALLOWED=NO -only-testing:MuxiTests/SpacingTokenTests -only-testing:MuxiTests/RadiusTokenTests 2>&1 | tail -20` +Expected: FAIL — `MuxiTokens.Spacing` not defined + +**Step 3: Write minimal implementation** + +Append to `MuxiTokens` enum in `MuxiTokens.swift`: + +```swift + // MARK: - Spacing (4pt grid) + + enum Spacing { + static let xs: CGFloat = 4 + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let xl: CGFloat = 24 + static let xxl: CGFloat = 32 + } + + // MARK: - Radius + + enum Radius { + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let full: CGFloat = 9999 + } + + // MARK: - Typography + + enum Typography { + static let largeTitle = Font.system(.title2, weight: .semibold) + static let title = Font.system(.headline, weight: .semibold) + static let body = Font.system(.body) + static let caption = Font.system(.caption) + static let label = Font.system(.footnote, weight: .medium) + } +``` + +**Step 4: Run test to verify it passes** + +Run: same as Step 2 +Expected: PASS + +**Step 5: Commit** + +```bash +git add ios/Muxi/DesignSystem/MuxiTokens.swift ios/Muxi/Tests/DesignTokenTests.swift +git commit -m "feat: add spacing, radius, and typography tokens" +``` + +--- + +### Task 3: Add Motion Tokens with Reduce Motion Support + +**Files:** +- Modify: `ios/Muxi/DesignSystem/MuxiTokens.swift` +- Modify: `ios/Muxi/Tests/DesignTokenTests.swift` + +**Step 1: Write the failing test** + +Append to `DesignTokenTests.swift`: + +```swift +@Suite("Design Tokens — Motion") +struct MotionTokenTests { + @Test func motionTokensExist() { + // Verify all motion tokens are accessible + _ = MuxiTokens.Motion.appear + _ = MuxiTokens.Motion.tap + _ = MuxiTokens.Motion.transition + _ = MuxiTokens.Motion.subtle + } + + @Test func reducedMotionReturnsSubtleAnimations() { + let reduced = MuxiTokens.Motion.resolved(reduceMotion: true) + // When reduce motion is on, all animations should use the same subtle curve + // We can't easily test Animation equality, but we verify the API exists + _ = reduced.appear + _ = reduced.tap + _ = reduced.transition + } +} +``` + +**Step 2: Run test to verify it fails** + +Expected: FAIL — `MuxiTokens.Motion` not defined + +**Step 3: Write minimal implementation** + +Append to `MuxiTokens` enum: + +```swift + // MARK: - Motion + + enum Motion { + static let appear = Animation.spring(duration: 0.4, bounce: 0.15) + static let tap = Animation.spring(duration: 0.2, bounce: 0.2) + static let transition = Animation.spring(duration: 0.35, bounce: 0.1) + static let subtle = Animation.easeInOut(duration: 0.2) + + /// Resolved motion set respecting accessibility preferences + static func resolved(reduceMotion: Bool) -> ResolvedMotion { + ResolvedMotion(reduceMotion: reduceMotion) + } + } + + struct ResolvedMotion { + let reduceMotion: Bool + + var appear: Animation { reduceMotion ? .easeInOut(duration: 0.2) : Motion.appear } + var tap: Animation { reduceMotion ? .easeInOut(duration: 0.15) : Motion.tap } + var transition: Animation { reduceMotion ? .easeInOut(duration: 0.2) : Motion.transition } + var subtle: Animation { Motion.subtle } // already subtle + } +``` + +**Step 4: Run test to verify it passes** + +Expected: PASS + +**Step 5: Commit** + +```bash +git add ios/Muxi/DesignSystem/MuxiTokens.swift ios/Muxi/Tests/DesignTokenTests.swift +git commit -m "feat: add motion tokens with reduce motion support" +``` + +--- + +### Task 4: Migrate ErrorBannerView to Design Tokens + +**Files:** +- Modify: `ios/Muxi/Views/Common/ErrorBannerView.swift` + +**Step 1: Identify all hardcoded values to replace** + +Current hardcoded values in `ErrorBannerView.swift`: +- `.red`, `.orange`, `.blue` → `MuxiTokens.Colors.error/warning/info` +- Padding `12` → `MuxiTokens.Spacing.md` +- Corner radius `10` → `MuxiTokens.Radius.sm` (closest: 8, but we round up to md=12 for cards) +- Opacity `0.12` → `MuxiTokens.Colors.accentSubtle` pattern (keep 0.12 as semantic-color specific) +- Opacity `0.3` → border opacity pattern +- Padding `.horizontal 16` → `MuxiTokens.Spacing.lg` +- Animation `.easeInOut(duration: 0.25)` → `MuxiTokens.Motion.subtle` + +**Step 2: Apply replacements** + +Replace in `ErrorBannerView.swift`: + +```swift +// BannerStyle.color computed property — replace: +case .error: return MuxiTokens.Colors.error // was .red +case .warning: return MuxiTokens.Colors.warning // was .orange +case .info: return MuxiTokens.Colors.info // was .blue + +// Layout — replace all hardcoded values: +.padding(MuxiTokens.Spacing.md) // was 12 +.clipShape(RoundedRectangle(cornerRadius: MuxiTokens.Radius.md)) // was 10 +.padding(.horizontal, MuxiTokens.Spacing.lg) // was 16 +.padding(.top, MuxiTokens.Spacing.sm) // was 8 +.animation(MuxiTokens.Motion.subtle, value: ...) // was .easeInOut(duration: 0.25) +``` + +**Step 3: Build to verify no compile errors** + +Run: `xcodebuild build -project ios/Muxi.xcodeproj -scheme Muxi -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -10` +Expected: BUILD SUCCEEDED + +**Step 4: Commit** + +```bash +git add ios/Muxi/Views/Common/ErrorBannerView.swift +git commit -m "refactor: migrate ErrorBannerView to design tokens" +``` + +--- + +### Task 5: Migrate ReconnectingOverlay to Design Tokens + +**Files:** +- Modify: `ios/Muxi/Views/Common/ReconnectingOverlay.swift` + +**Step 1: Identify all hardcoded values to replace** + +Current hardcoded values: +- `Color.black.opacity(0.5)` → `MuxiTokens.Colors.surfaceBase.opacity(0.7)` (warm dark backdrop) +- `.white` text → `MuxiTokens.Colors.textPrimary` +- `.white.opacity(0.7)` text → `MuxiTokens.Colors.textSecondary` +- `.white` button text → `MuxiTokens.Colors.textPrimary` +- `.white.opacity(0.15)` button bg → `MuxiTokens.Colors.accentSubtle` +- `.white.opacity(0.3)` button stroke → `MuxiTokens.Colors.borderAccent` +- Padding `.horizontal 24` → `MuxiTokens.Spacing.xl` +- Padding `.vertical 8` → `MuxiTokens.Spacing.sm` +- Padding `32` → `MuxiTokens.Spacing.xxl` +- Corner radius `16` → `MuxiTokens.Radius.lg` +- Spacing `20` → `MuxiTokens.Spacing.xl` (closest: 24, acceptable rounding) +- Remove `.environment(\.colorScheme, .dark)` — app is dark-only now + +**Step 2: Apply replacements** + +Replace all values per mapping above. Remove the `.environment(\.colorScheme, .dark)` modifier since the entire app will be dark-only. Use `.regularMaterial` → replace with `MuxiTokens.Colors.surfaceElevated` solid background for consistency. + +**Step 3: Build to verify** + +Expected: BUILD SUCCEEDED + +**Step 4: Commit** + +```bash +git add ios/Muxi/Views/Common/ReconnectingOverlay.swift +git commit -m "refactor: migrate ReconnectingOverlay to design tokens" +``` + +--- + +### Task 6: Migrate ExtendedKeyboardView to Design Tokens + +**Files:** +- Modify: `ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift` + +**Step 1: Identify all hardcoded values to replace** + +Current values (theme-aware but with hardcoded sizing): +- `theme.foreground.color.opacity(0.3)` divider → `MuxiTokens.Colors.borderDefault` +- `theme.foreground.color` button text → `MuxiTokens.Colors.textPrimary` +- `theme.foreground.color.opacity(0.1)` button bg → `MuxiTokens.Colors.accentMuted` +- `theme.background.color` container bg → keep as-is (terminal theme background) +- Active modifier bg: `theme.foreground.color` → `MuxiTokens.Colors.accentDefault` +- Inactive modifier bg: `theme.foreground.color.opacity(0.1)` → `MuxiTokens.Colors.accentMuted` +- Corner radius `6` → `MuxiTokens.Radius.sm` (8) +- `minWidth: 36, minHeight: 32` → keep (touch target, not a design token) +- Font `size: 14, weight: .medium` → `MuxiTokens.Typography.label` +- Padding `.horizontal 8, .vertical 4` → `MuxiTokens.Spacing.sm`, `MuxiTokens.Spacing.xs` +- Frame height `44` → keep (standard iOS touch target) + +**Note:** The keyboard background should remain `theme.background.color` since it sits directly above the terminal and must match the terminal theme. Only the buttons/dividers use design tokens. + +**Step 2: Apply replacements** + +**Step 3: Build to verify** + +Expected: BUILD SUCCEEDED + +**Step 4: Commit** + +```bash +git add ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift +git commit -m "refactor: migrate ExtendedKeyboardView to design tokens" +``` + +--- + +### Task 7: Migrate PaneContainerView to Design Tokens + +**Files:** +- Modify: `ios/Muxi/Views/Terminal/PaneContainerView.swift` + +**Step 1: Identify all hardcoded values to replace** + +Current values: +- `theme.foreground.color.opacity(0.2)` separator → `MuxiTokens.Colors.borderDefault` +- `Color(UIColor.systemBackground)` tab bar bg → `MuxiTokens.Colors.surfaceRaised` +- `.accentColor.opacity(0.3)` active tab → `MuxiTokens.Colors.accentSubtle` +- `.clear` inactive tab → `.clear` (keep) +- `.accentColor.opacity(0.5 or 0)` pane border → `MuxiTokens.Colors.borderAccent` or `.clear` +- Padding `.horizontal 12, .vertical 6` → `MuxiTokens.Spacing.md`, `MuxiTokens.Spacing.xs` (use 4+2 padding combo) +- Corner radius `8` → `MuxiTokens.Radius.sm` +- Frame height `36` → keep (tab bar height) +- Spacing `12` → `MuxiTokens.Spacing.md` +- Line width `2` → keep (border width, not a spacing token) + +**Step 2: Apply replacements** + +**Step 3: Build to verify** + +Expected: BUILD SUCCEEDED + +**Step 4: Commit** + +```bash +git add ios/Muxi/Views/Terminal/PaneContainerView.swift +git commit -m "refactor: migrate PaneContainerView to design tokens" +``` + +--- + +### Task 8: Migrate ThemeSettingsView and QuickActionButton + +**Files:** +- Modify: `ios/Muxi/Views/Settings/ThemeSettingsView.swift` +- Modify: `ios/Muxi/Views/QuickAction/QuickActionView.swift` (QuickActionButton section) + +**Step 1: ThemeSettingsView replacements** + +- `.foregroundStyle(.blue)` checkmark → `.foregroundStyle(MuxiTokens.Colors.accentDefault)` +- Corner radius `2` → `MuxiTokens.Radius.sm` (theme preview swatch, but 8 is too big for 20x12 swatch — keep 2 for micro elements, or use 4) +- Spacing `2` → keep (micro element spacing) +- Spacing `4` → `MuxiTokens.Spacing.xs` + +**Step 2: QuickActionButton replacements** + +- Font `size: 20, weight: .semibold` → keep (icon sizing, not typography) +- Frame `52x52` → keep (touch target) +- `.foregroundStyle(.white)` → `MuxiTokens.Colors.textPrimary` +- `.fill(.tint)` → `.fill(MuxiTokens.Colors.accentDefault)` +- Shadow `.black.opacity(0.25), radius: 4` → remove shadow (design spec: no shadows on cards/buttons; depth via surface layers) + +**Step 3: Build to verify** + +Expected: BUILD SUCCEEDED + +**Step 4: Commit** + +```bash +git add ios/Muxi/Views/Settings/ThemeSettingsView.swift ios/Muxi/Views/QuickAction/QuickActionView.swift +git commit -m "refactor: migrate ThemeSettingsView and QuickActionButton to design tokens" +``` + +--- + +### Task 9: Set App-Wide Dark Mode and Accent Color + +**Files:** +- Modify: `ios/Muxi/MuxiApp.swift` (or root app entry point) + +**Step 1: Identify app entry point** + +Read `ios/Muxi/MuxiApp.swift` to find the `@main` App struct. + +**Step 2: Apply dark-only and accent color** + +Add to the root view: +```swift +.preferredColorScheme(.dark) +.tint(MuxiTokens.Colors.accentDefault) +``` + +This ensures: +- Entire app is always dark (Dark Only decision) +- All system controls (toggles, navigation links, etc.) use Lavender accent +- No need to set `.environment(\.colorScheme, .dark)` on individual views + +**Step 3: Build to verify** + +Expected: BUILD SUCCEEDED + +**Step 4: Commit** + +```bash +git add ios/Muxi/MuxiApp.swift +git commit -m "feat: set app-wide dark mode and Lavender accent" +``` + +--- + +### Task 10: Final Integration Test and Cleanup + +**Files:** +- All modified files +- Modify: `ios/Muxi/Tests/DesignTokenTests.swift` + +**Step 1: Run full test suite** + +Run: `xcodebuild test -project ios/Muxi.xcodeproj -scheme Muxi -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -30` +Expected: ALL TESTS PASS + +**Step 2: Grep for remaining hardcoded colors** + +Run: `grep -rn '\.red\b\|\.orange\b\|\.blue\b\|Color\.white\|Color\.black\|UIColor\.system' ios/Muxi/Views/` + +Any remaining hits should be: +- In files we intentionally didn't migrate (e.g., views not yet created) +- Or justified (like `.listStyle` modifiers that use system colors internally) + +If unjustified hardcoded values remain, migrate them. + +**Step 3: Grep for remaining hardcoded spacing** + +Run: `grep -rn 'padding([0-9]\|cornerRadius([0-9]\|spacing: [0-9]' ios/Muxi/Views/` + +Replace any remaining raw numbers with tokens. + +**Step 4: Commit cleanup if needed** + +```bash +git add -A +git commit -m "refactor: clean up remaining hardcoded values" +``` + +**Step 5: Run full build one more time** + +Run: `xcodebuild build -project ios/Muxi.xcodeproj -scheme Muxi -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' CODE_SIGNING_ALLOWED=NO 2>&1 | tail -10` +Expected: BUILD SUCCEEDED From c2c24072938c2d651f408ee2bec9ec448b70c908 Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 01:42:01 +0900 Subject: [PATCH 03/14] feat: add MuxiTokens design token system with comprehensive tests Defines all semantic design tokens: colors (surface, accent, text, border, semantic), spacing (4pt grid), radius, typography, and motion (with reduceMotion accessibility support). Includes Color.rgbComponents extension for test assertions. Also fixes pre-existing build errors in test files: missing Foundation import in ThemeManagerTests/ThemeTests, type mismatch in TmuxControlServiceTests. Co-Authored-By: Claude Opus 4.6 --- ios/Muxi/DesignSystem/MuxiTokens.swift | 107 ++++++++++++++++++ ios/MuxiTests/DesignTokenTests.swift | 97 ++++++++++++++++ ios/MuxiTests/Models/ThemeTests.swift | 1 + .../Services/ThemeManagerTests.swift | 1 + .../Services/TmuxControlServiceTests.swift | 2 +- 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 ios/Muxi/DesignSystem/MuxiTokens.swift create mode 100644 ios/MuxiTests/DesignTokenTests.swift diff --git a/ios/Muxi/DesignSystem/MuxiTokens.swift b/ios/Muxi/DesignSystem/MuxiTokens.swift new file mode 100644 index 0000000..2f05dad --- /dev/null +++ b/ios/Muxi/DesignSystem/MuxiTokens.swift @@ -0,0 +1,107 @@ +import SwiftUI + +// MARK: - Design Tokens + +/// Muxi semantic design token system. +/// All visual constants (colors, spacing, radii, typography, motion) live here. +/// Views reference tokens by role, never by raw value. +enum MuxiTokens { + + // MARK: - Colors + + enum Colors { + // Surface (background layers) — purple undertone, ~6% lightness steps + static let surfaceBase = Color(red: 0.071, green: 0.055, blue: 0.094) // #120E18 + static let surfaceDefault = Color(red: 0.102, green: 0.082, blue: 0.125) // #1A1520 + static let surfaceRaised = Color(red: 0.141, green: 0.118, blue: 0.173) // #241E2C + static let surfaceElevated = Color(red: 0.180, green: 0.153, blue: 0.220) // #2E2738 + + // Accent (Lavender) + static let accentDefault = Color(red: 0.710, green: 0.659, blue: 0.835) // #B5A8D5 + static let accentBright = Color(red: 0.831, green: 0.784, blue: 0.941) // #D4C8F0 + static let accentSubtle = accentDefault.opacity(0.12) + static let accentMuted = accentDefault.opacity(0.06) + + // Text + static let textPrimary = Color(red: 0.918, green: 0.878, blue: 0.949) // #EAE0F2 + static let textSecondary = Color(red: 0.608, green: 0.565, blue: 0.659) // #9B90A8 + static let textTertiary = Color(red: 0.420, green: 0.380, blue: 0.471) // #6B6178 + static let textInverse = surfaceBase + + // Border / Divider + static let borderDefault = Color.white.opacity(0.08) + static let borderStrong = Color.white.opacity(0.15) + static let borderAccent = accentDefault.opacity(0.30) + + // Semantic (status) + static let error = Color(red: 1.000, green: 0.420, blue: 0.420) // #FF6B6B + static let success = Color(red: 0.420, green: 0.796, blue: 0.467) // #6BCB77 + static let warning = Color(red: 1.000, green: 0.851, blue: 0.239) // #FFD93D + static let info = Color(red: 0.455, green: 0.725, blue: 1.000) // #74B9FF + } + + // MARK: - Spacing (4pt grid) + + enum Spacing { + static let xs: CGFloat = 4 + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let xl: CGFloat = 24 + static let xxl: CGFloat = 32 + } + + // MARK: - Radius + + enum Radius { + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 + static let full: CGFloat = 9999 + } + + // MARK: - Typography + + enum Typography { + static let largeTitle = Font.system(.title2, weight: .semibold) + static let title = Font.system(.headline, weight: .semibold) + static let body = Font.system(.body) + static let caption = Font.system(.caption) + static let label = Font.system(.footnote, weight: .medium) + } + + // MARK: - Motion + + enum Motion { + static let appear = Animation.spring(duration: 0.4, bounce: 0.15) + static let tap = Animation.spring(duration: 0.2, bounce: 0.2) + static let transition = Animation.spring(duration: 0.35, bounce: 0.1) + static let subtle = Animation.easeInOut(duration: 0.2) + + /// Resolved motion set respecting accessibility preferences + static func resolved(reduceMotion: Bool) -> ResolvedMotion { + ResolvedMotion(reduceMotion: reduceMotion) + } + } + + struct ResolvedMotion { + let reduceMotion: Bool + + var appear: Animation { reduceMotion ? .easeInOut(duration: 0.2) : Motion.appear } + var tap: Animation { reduceMotion ? .easeInOut(duration: 0.15) : Motion.tap } + var transition: Animation { reduceMotion ? .easeInOut(duration: 0.2) : Motion.transition } + var subtle: Animation { Motion.subtle } + } +} + +// MARK: - Color Helpers + +extension Color { + /// Extract approximate RGB components (for testing) + var rgbComponents: (red: CGFloat, green: CGFloat, blue: CGFloat) { + let uiColor = UIColor(self) + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + uiColor.getRed(&r, green: &g, blue: &b, alpha: &a) + return (r, g, b) + } +} diff --git a/ios/MuxiTests/DesignTokenTests.swift b/ios/MuxiTests/DesignTokenTests.swift new file mode 100644 index 0000000..feff959 --- /dev/null +++ b/ios/MuxiTests/DesignTokenTests.swift @@ -0,0 +1,97 @@ +import SwiftUI +import Testing +@testable import Muxi + +@Suite("Design Tokens — Colors") +struct ColorTokenTests { + @Test func surfaceLayersHaveIncreasingLightness() { + let surfaces = [ + MuxiTokens.Colors.surfaceBase, + MuxiTokens.Colors.surfaceDefault, + MuxiTokens.Colors.surfaceRaised, + MuxiTokens.Colors.surfaceElevated + ] + for i in 0.. bCurrent, "Surface layer \(i+1) should be lighter than \(i)") + } + } + + @Test func accentColorIsDefined() { + let accent = MuxiTokens.Colors.accentDefault + let (r, g, b) = accent.rgbComponents + #expect(r > 0.6 && r < 0.8) + #expect(g > 0.5 && g < 0.75) + #expect(b > 0.75 && b < 0.95) + } + + @Test func semanticColorsAreDefined() { + _ = MuxiTokens.Colors.error + _ = MuxiTokens.Colors.success + _ = MuxiTokens.Colors.warning + _ = MuxiTokens.Colors.info + } +} + +@Suite("Design Tokens — Spacing") +struct SpacingTokenTests { + @Test func allSpacingsAreMultiplesOf4() { + let spacings: [CGFloat] = [ + MuxiTokens.Spacing.xs, + MuxiTokens.Spacing.sm, + MuxiTokens.Spacing.md, + MuxiTokens.Spacing.lg, + MuxiTokens.Spacing.xl, + MuxiTokens.Spacing.xxl + ] + for spacing in spacings { + #expect(spacing.truncatingRemainder(dividingBy: 4) == 0, + "\(spacing) is not a multiple of 4") + } + } + + @Test func spacingsAreStrictlyIncreasing() { + let spacings: [CGFloat] = [ + MuxiTokens.Spacing.xs, + MuxiTokens.Spacing.sm, + MuxiTokens.Spacing.md, + MuxiTokens.Spacing.lg, + MuxiTokens.Spacing.xl, + MuxiTokens.Spacing.xxl + ] + for i in 0..= 8) + } +} + +@Suite("Design Tokens — Motion") +struct MotionTokenTests { + @Test func motionTokensExist() { + _ = MuxiTokens.Motion.appear + _ = MuxiTokens.Motion.tap + _ = MuxiTokens.Motion.transition + _ = MuxiTokens.Motion.subtle + } + + @Test func reducedMotionReturnsSubtleAnimations() { + let reduced = MuxiTokens.Motion.resolved(reduceMotion: true) + _ = reduced.appear + _ = reduced.tap + _ = reduced.transition + } +} diff --git a/ios/MuxiTests/Models/ThemeTests.swift b/ios/MuxiTests/Models/ThemeTests.swift index 8374976..1bcb856 100644 --- a/ios/MuxiTests/Models/ThemeTests.swift +++ b/ios/MuxiTests/Models/ThemeTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import Muxi diff --git a/ios/MuxiTests/Services/ThemeManagerTests.swift b/ios/MuxiTests/Services/ThemeManagerTests.swift index de80b9c..c4ca534 100644 --- a/ios/MuxiTests/Services/ThemeManagerTests.swift +++ b/ios/MuxiTests/Services/ThemeManagerTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import Muxi diff --git a/ios/MuxiTests/Services/TmuxControlServiceTests.swift b/ios/MuxiTests/Services/TmuxControlServiceTests.swift index a1e32e9..7a03322 100644 --- a/ios/MuxiTests/Services/TmuxControlServiceTests.swift +++ b/ios/MuxiTests/Services/TmuxControlServiceTests.swift @@ -45,7 +45,7 @@ final class TmuxControlServiceTests: XCTestCase { func testHandleControlModeOutput() { let service = TmuxControlService() var receivedPaneId: String? - var receivedData: String? + var receivedData: Data? service.onPaneOutput = { paneId, data in receivedPaneId = paneId From ea9d9d35514cc4e1f564dbfed77b60bf31b51300 Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 01:51:38 +0900 Subject: [PATCH 04/14] test: add Typography suite and improve Motion test coverage - Add TypographyTokenTests with existence checks for all 5 tokens - Test both reduceMotion paths (true and false) in resolvedMotion Co-Authored-By: Claude Opus 4.6 --- ios/MuxiTests/DesignTokenTests.swift | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/ios/MuxiTests/DesignTokenTests.swift b/ios/MuxiTests/DesignTokenTests.swift index feff959..7452d44 100644 --- a/ios/MuxiTests/DesignTokenTests.swift +++ b/ios/MuxiTests/DesignTokenTests.swift @@ -79,6 +79,17 @@ struct RadiusTokenTests { } } +@Suite("Design Tokens — Typography") +struct TypographyTokenTests { + @Test func allTypographyTokensExist() { + _ = MuxiTokens.Typography.largeTitle + _ = MuxiTokens.Typography.title + _ = MuxiTokens.Typography.body + _ = MuxiTokens.Typography.caption + _ = MuxiTokens.Typography.label + } +} + @Suite("Design Tokens — Motion") struct MotionTokenTests { @Test func motionTokensExist() { @@ -88,10 +99,19 @@ struct MotionTokenTests { _ = MuxiTokens.Motion.subtle } - @Test func reducedMotionReturnsSubtleAnimations() { + @Test func resolvedMotionBothPaths() { + // Both paths should produce valid animations without crashing let reduced = MuxiTokens.Motion.resolved(reduceMotion: true) + let normal = MuxiTokens.Motion.resolved(reduceMotion: false) + + // Verify reduced and normal both resolve all properties _ = reduced.appear _ = reduced.tap _ = reduced.transition + _ = reduced.subtle + _ = normal.appear + _ = normal.tap + _ = normal.transition + _ = normal.subtle } } From 6bfe3ba9ae52232770aa5d407c5b5bab6b90bbe4 Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 01:56:24 +0900 Subject: [PATCH 05/14] refactor: migrate ErrorBannerView to design tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all hardcoded visual constants with MuxiTokens references: - Colors: .red/.orange/.blue → MuxiTokens.Colors.error/warning/info - Padding 12 → MuxiTokens.Spacing.md - Corner radius 10 → MuxiTokens.Radius.md (12pt) - Horizontal padding 16 → MuxiTokens.Spacing.lg - Top padding 8, VStack spacing 8 → MuxiTokens.Spacing.sm - Animation .easeInOut(0.25) → MuxiTokens.Motion.subtle Opacity values (0.12 fill, 0.3 stroke) kept as-is — they are semantic-color-specific, not design tokens. Co-Authored-By: Claude Opus 4.6 --- ios/Muxi/Views/Common/ErrorBannerView.swift | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ios/Muxi/Views/Common/ErrorBannerView.swift b/ios/Muxi/Views/Common/ErrorBannerView.swift index 3ca2aee..ae64a6f 100644 --- a/ios/Muxi/Views/Common/ErrorBannerView.swift +++ b/ios/Muxi/Views/Common/ErrorBannerView.swift @@ -11,9 +11,9 @@ enum BannerStyle { /// Tint color associated with this style. var color: Color { switch self { - case .error: return .red - case .warning: return .orange - case .info: return .blue + case .error: return MuxiTokens.Colors.error + case .warning: return MuxiTokens.Colors.warning + case .info: return MuxiTokens.Colors.info } } @@ -56,7 +56,7 @@ struct ErrorBannerView: View { var onRetry: (() -> Void)? var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: MuxiTokens.Spacing.sm) { HStack(alignment: .top, spacing: 10) { // Leading icon Image(systemName: style.icon) @@ -98,16 +98,16 @@ struct ErrorBannerView: View { .accessibilityLabel("Retry") } } - .padding(12) + .padding(MuxiTokens.Spacing.md) .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) + RoundedRectangle(cornerRadius: MuxiTokens.Radius.md, style: .continuous) .fill(style.color.opacity(0.12)) ) .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) + RoundedRectangle(cornerRadius: MuxiTokens.Radius.md, style: .continuous) .strokeBorder(style.color.opacity(0.3), lineWidth: 1) ) - .padding(.horizontal, 16) + .padding(.horizontal, MuxiTokens.Spacing.lg) .transition(.move(edge: .top).combined(with: .opacity)) .accessibilityElement(children: .combine) .accessibilityLabel("\(style.accessibilityLabel): \(message)") @@ -132,17 +132,17 @@ struct ErrorBannerModifier: ViewModifier { message: message, style: style, onDismiss: { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(MuxiTokens.Motion.subtle) { isPresented = false } onDismiss?() }, onRetry: onRetry ) - .padding(.top, 8) + .padding(.top, MuxiTokens.Spacing.sm) } } - .animation(.easeInOut(duration: 0.25), value: isPresented) + .animation(MuxiTokens.Motion.subtle, value: isPresented) } } From bc1f6e0db5d4c768b4f03b4150d0d3c094c9820e Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 02:00:37 +0900 Subject: [PATCH 06/14] refactor: migrate ReconnectingOverlay to design tokens Replace all hardcoded colors, spacing, radius values with MuxiTokens references for consistent dark theme styling across the app. Co-Authored-By: Claude Opus 4.6 --- .../Views/Common/ReconnectingOverlay.swift | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/ios/Muxi/Views/Common/ReconnectingOverlay.swift b/ios/Muxi/Views/Common/ReconnectingOverlay.swift index 36d2dc8..67c52e1 100644 --- a/ios/Muxi/Views/Common/ReconnectingOverlay.swift +++ b/ios/Muxi/Views/Common/ReconnectingOverlay.swift @@ -37,23 +37,23 @@ struct ReconnectingOverlay: View { var body: some View { ZStack { // Semi-transparent backdrop - Color.black.opacity(0.5) + MuxiTokens.Colors.surfaceBase.opacity(0.7) .ignoresSafeArea() // Centered card - VStack(spacing: 20) { + VStack(spacing: MuxiTokens.Spacing.xl) { ProgressView() .progressViewStyle(.circular) .controlSize(.large) - .tint(.white) + .tint(MuxiTokens.Colors.textPrimary) Text("Reconnecting...") .font(.headline) - .foregroundStyle(.white) + .foregroundStyle(MuxiTokens.Colors.textPrimary) Text(attemptText) .font(.subheadline) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(MuxiTokens.Colors.textSecondary) if let onCancel { Button { @@ -61,27 +61,26 @@ struct ReconnectingOverlay: View { } label: { Text("Cancel") .font(.subheadline.weight(.medium)) - .foregroundStyle(.white) - .padding(.horizontal, 24) - .padding(.vertical, 8) + .foregroundStyle(MuxiTokens.Colors.textPrimary) + .padding(.horizontal, MuxiTokens.Spacing.xl) + .padding(.vertical, MuxiTokens.Spacing.sm) .background( Capsule() - .fill(.white.opacity(0.15)) + .fill(MuxiTokens.Colors.accentSubtle) ) .overlay( Capsule() - .strokeBorder(.white.opacity(0.3), lineWidth: 1) + .strokeBorder(MuxiTokens.Colors.borderAccent, lineWidth: 1) ) } .buttonStyle(.plain) .accessibilityLabel("Cancel reconnection") } } - .padding(32) + .padding(MuxiTokens.Spacing.xxl) .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.regularMaterial) - .environment(\.colorScheme, .dark) + RoundedRectangle(cornerRadius: MuxiTokens.Radius.lg, style: .continuous) + .fill(MuxiTokens.Colors.surfaceElevated) ) } .transition(.opacity) From da70f01a6014ea3de0584e3705fb39f324dd7ba0 Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 02:04:57 +0900 Subject: [PATCH 07/14] refactor: migrate ExtendedKeyboardView to design tokens Co-Authored-By: Claude Opus 4.6 --- .../Views/Terminal/ExtendedKeyboardView.swift | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift b/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift index 317e896..0e4d500 100644 --- a/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift +++ b/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift @@ -35,7 +35,7 @@ struct ExtendedKeyboardView: View { Divider() .frame(height: 24) - .background(theme.foreground.color.opacity(0.3)) + .background(MuxiTokens.Colors.borderDefault) // Arrow keys keyButton("\u{2190}") { send(key: .arrowLeft) } // left arrow @@ -52,18 +52,18 @@ struct ExtendedKeyboardView: View { Button { onDismissKeyboard?() } label: { Image(systemName: "keyboard.chevron.compact.down") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(theme.foreground.color) + .font(MuxiTokens.Typography.label) + .foregroundStyle(MuxiTokens.Colors.textPrimary) .frame(minWidth: 36, minHeight: 32) - .background(theme.foreground.color.opacity(0.1)) - .cornerRadius(6) + .background(MuxiTokens.Colors.accentMuted) + .cornerRadius(MuxiTokens.Radius.sm) } .buttonStyle(.plain) .accessibilityLabel("Dismiss Keyboard") } } - .padding(.horizontal, 8) - .padding(.vertical, 4) + .padding(.horizontal, MuxiTokens.Spacing.sm) + .padding(.vertical, MuxiTokens.Spacing.xs) .frame(height: 44) .background(theme.background.color) } @@ -74,11 +74,11 @@ struct ExtendedKeyboardView: View { private func keyButton(_ label: String, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) - .font(.system(size: 14, weight: .medium, design: .monospaced)) - .foregroundStyle(theme.foreground.color) + .font(MuxiTokens.Typography.label.monospaced()) + .foregroundStyle(MuxiTokens.Colors.textPrimary) .frame(minWidth: 36, minHeight: 32) - .background(theme.foreground.color.opacity(0.1)) - .cornerRadius(6) + .background(MuxiTokens.Colors.accentMuted) + .cornerRadius(MuxiTokens.Radius.sm) } .buttonStyle(.plain) } @@ -89,11 +89,11 @@ struct ExtendedKeyboardView: View { private func modifierButton(_ label: String, active: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) - .font(.system(size: 14, weight: .medium, design: .monospaced)) - .foregroundStyle(active ? theme.background.color : theme.foreground.color) + .font(MuxiTokens.Typography.label.monospaced()) + .foregroundStyle(active ? theme.background.color : MuxiTokens.Colors.textPrimary) .frame(minWidth: 36, minHeight: 32) - .background(active ? theme.foreground.color : theme.foreground.color.opacity(0.1)) - .cornerRadius(6) + .background(active ? MuxiTokens.Colors.accentDefault : MuxiTokens.Colors.accentMuted) + .cornerRadius(MuxiTokens.Radius.sm) } .buttonStyle(.plain) } From aca063c140ca740a0b625dbc897343bccc45a78b Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 02:08:18 +0900 Subject: [PATCH 08/14] refactor: migrate PaneContainerView to design tokens Co-Authored-By: Claude Opus 4.6 --- .../Views/Terminal/PaneContainerView.swift | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ios/Muxi/Views/Terminal/PaneContainerView.swift b/ios/Muxi/Views/Terminal/PaneContainerView.swift index 26c3497..6521ea8 100644 --- a/ios/Muxi/Views/Terminal/PaneContainerView.swift +++ b/ios/Muxi/Views/Terminal/PaneContainerView.swift @@ -133,7 +133,7 @@ struct PaneContainerView: View { @ViewBuilder private var paneTabBar: some View { ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { + HStack(spacing: MuxiTokens.Spacing.md) { ForEach(panes.indices, id: \.self) { index in Button { selectedPaneIndex = index @@ -142,14 +142,14 @@ struct PaneContainerView: View { } label: { Text("Pane \(index + 1)") .font(.caption) - .padding(.horizontal, 12) - .padding(.vertical, 6) + .padding(.horizontal, MuxiTokens.Spacing.md) + .padding(.vertical, MuxiTokens.Spacing.xs) .background( index == selectedPaneIndex - ? Color.accentColor.opacity(0.3) + ? MuxiTokens.Colors.accentSubtle : Color.clear ) - .cornerRadius(8) + .cornerRadius(MuxiTokens.Radius.sm) } .buttonStyle(.plain) } @@ -157,7 +157,7 @@ struct PaneContainerView: View { .padding(.horizontal) } .frame(height: 36) - .background(Color(UIColor.systemBackground)) + .background(MuxiTokens.Colors.surfaceRaised) } // MARK: - Regular (iPad) Layout @@ -181,7 +181,9 @@ struct PaneContainerView: View { .overlay( RoundedRectangle(cornerRadius: 0) .stroke( - Color.accentColor.opacity(activePaneId == pane.id ? 0.5 : 0), + activePaneId == pane.id + ? MuxiTokens.Colors.borderAccent + : Color.clear, lineWidth: 2 ) ) @@ -205,7 +207,7 @@ struct PaneContainerView: View { /// Draw 1pt separator lines along the edges where two panes meet. @ViewBuilder private func separatorLines(frames: [PaneLayout.Frame], containerSize: CGSize) -> some View { - let separatorColor = theme.foreground.color.opacity(0.2) + let separatorColor = MuxiTokens.Colors.borderDefault ForEach(0.. Date: Tue, 3 Mar 2026 02:10:00 +0900 Subject: [PATCH 09/14] refactor: migrate ThemeSettingsView and QuickActionButton to design tokens Co-Authored-By: Claude Opus 4.6 --- ios/Muxi/Views/QuickAction/QuickActionButton.swift | 5 ++--- ios/Muxi/Views/Settings/ThemeSettingsView.swift | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ios/Muxi/Views/QuickAction/QuickActionButton.swift b/ios/Muxi/Views/QuickAction/QuickActionButton.swift index 60f607e..4b2e3f4 100644 --- a/ios/Muxi/Views/QuickAction/QuickActionButton.swift +++ b/ios/Muxi/Views/QuickAction/QuickActionButton.swift @@ -20,10 +20,9 @@ struct QuickActionButton: View { } label: { Image(systemName: "bolt.fill") .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(MuxiTokens.Colors.textPrimary) .frame(width: 52, height: 52) - .background(Circle().fill(.tint)) - .shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 2) + .background(Circle().fill(MuxiTokens.Colors.accentDefault)) } .accessibilityLabel("Quick Actions") .sheet(isPresented: $showingActions) { diff --git a/ios/Muxi/Views/Settings/ThemeSettingsView.swift b/ios/Muxi/Views/Settings/ThemeSettingsView.swift index a3ba3d0..b546ec3 100644 --- a/ios/Muxi/Views/Settings/ThemeSettingsView.swift +++ b/ios/Muxi/Views/Settings/ThemeSettingsView.swift @@ -12,7 +12,7 @@ struct ThemeSettingsView: View { themeManager.selectTheme(theme) } label: { HStack { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: MuxiTokens.Spacing.xs) { Text(theme.name) .font(.body) .foregroundStyle(.primary) @@ -31,7 +31,7 @@ struct ThemeSettingsView: View { if theme.id == themeManager.currentTheme.id { Image(systemName: "checkmark") - .foregroundStyle(.blue) + .foregroundStyle(MuxiTokens.Colors.accentDefault) } } } From 86c36067a7ba31c995b8b7f21f4f3b7cc975ce8c Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 02:11:28 +0900 Subject: [PATCH 10/14] feat: set app-wide dark mode and Lavender accent Applies .preferredColorScheme(.dark) and .tint(Lavender) at the app root so all system controls inherit the design system. Co-Authored-By: Claude Opus 4.6 --- ios/Muxi/App/MuxiApp.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/Muxi/App/MuxiApp.swift b/ios/Muxi/App/MuxiApp.swift index 4dac77e..33c86ee 100644 --- a/ios/Muxi/App/MuxiApp.swift +++ b/ios/Muxi/App/MuxiApp.swift @@ -6,6 +6,8 @@ struct MuxiApp: App { var body: some Scene { WindowGroup { ContentView() + .preferredColorScheme(.dark) + .tint(MuxiTokens.Colors.accentDefault) } .modelContainer(for: [Server.self]) } From ca4e5784416cef2f9c0ec794dc139ef92431b6ed Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 02:16:05 +0900 Subject: [PATCH 11/14] refactor: replace remaining hardcoded spacing in migrated views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ErrorBannerView: HStack spacing 10 → MuxiTokens.Spacing.md - ExtendedKeyboardView: HStack spacing 6 → MuxiTokens.Spacing.sm Co-Authored-By: Claude Opus 4.6 --- ios/Muxi/Views/Common/ErrorBannerView.swift | 2 +- ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Muxi/Views/Common/ErrorBannerView.swift b/ios/Muxi/Views/Common/ErrorBannerView.swift index ae64a6f..6187c40 100644 --- a/ios/Muxi/Views/Common/ErrorBannerView.swift +++ b/ios/Muxi/Views/Common/ErrorBannerView.swift @@ -57,7 +57,7 @@ struct ErrorBannerView: View { var body: some View { VStack(alignment: .leading, spacing: MuxiTokens.Spacing.sm) { - HStack(alignment: .top, spacing: 10) { + HStack(alignment: .top, spacing: MuxiTokens.Spacing.md) { // Leading icon Image(systemName: style.icon) .foregroundStyle(style.color) diff --git a/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift b/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift index 0e4d500..58511d7 100644 --- a/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift +++ b/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift @@ -19,7 +19,7 @@ struct ExtendedKeyboardView: View { // MARK: - Body var body: some View { - HStack(spacing: 6) { + HStack(spacing: MuxiTokens.Spacing.sm) { // Immediate keys keyButton("Esc") { send(key: .escape) } keyButton("Tab") { send(key: .tab) } From 3c24baea0f82497832355ee279a0775cb88b966a Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 02:24:45 +0900 Subject: [PATCH 12/14] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20reduce=20motion,=20TmuxInstallGuide,=20text=20color?= =?UTF-8?q?s,=20cornerRadius?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- ios/Muxi/DesignSystem/MuxiTokens.swift | 24 +++++++++++++++++ ios/Muxi/Views/Common/ErrorBannerView.swift | 9 ++++--- .../Views/Common/ReconnectingOverlay.swift | 26 +++++++++---------- .../Views/Settings/ThemeSettingsView.swift | 2 +- .../Views/Terminal/ExtendedKeyboardView.swift | 6 ++--- .../Views/Terminal/PaneContainerView.swift | 2 +- 6 files changed, 47 insertions(+), 22 deletions(-) diff --git a/ios/Muxi/DesignSystem/MuxiTokens.swift b/ios/Muxi/DesignSystem/MuxiTokens.swift index 2f05dad..122f962 100644 --- a/ios/Muxi/DesignSystem/MuxiTokens.swift +++ b/ios/Muxi/DesignSystem/MuxiTokens.swift @@ -94,6 +94,30 @@ enum MuxiTokens { } } +// MARK: - Reduce Motion View Modifier + +struct MuxiAnimationModifier: ViewModifier { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let animation: KeyPath + let value: V + + func body(content: Content) -> some View { + content.animation( + MuxiTokens.Motion.resolved(reduceMotion: reduceMotion)[keyPath: animation], + value: value + ) + } +} + +extension View { + func muxiAnimation( + _ animation: KeyPath, + value: some Equatable + ) -> some View { + modifier(MuxiAnimationModifier(animation: animation, value: value)) + } +} + // MARK: - Color Helpers extension Color { diff --git a/ios/Muxi/Views/Common/ErrorBannerView.swift b/ios/Muxi/Views/Common/ErrorBannerView.swift index 6187c40..f33af23 100644 --- a/ios/Muxi/Views/Common/ErrorBannerView.swift +++ b/ios/Muxi/Views/Common/ErrorBannerView.swift @@ -67,7 +67,7 @@ struct ErrorBannerView: View { // Message text Text(message) .font(.subheadline) - .foregroundStyle(.primary) + .foregroundStyle(MuxiTokens.Colors.textPrimary) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) @@ -78,7 +78,7 @@ struct ErrorBannerView: View { } label: { Image(systemName: "xmark") .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) + .foregroundStyle(MuxiTokens.Colors.textSecondary) } .buttonStyle(.plain) .accessibilityLabel("Dismiss") @@ -124,6 +124,7 @@ struct ErrorBannerModifier: ViewModifier { let style: BannerStyle var onDismiss: (() -> Void)? var onRetry: (() -> Void)? + @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content.overlay(alignment: .top) { @@ -132,7 +133,7 @@ struct ErrorBannerModifier: ViewModifier { message: message, style: style, onDismiss: { - withAnimation(MuxiTokens.Motion.subtle) { + withAnimation(MuxiTokens.Motion.resolved(reduceMotion: reduceMotion).subtle) { isPresented = false } onDismiss?() @@ -142,7 +143,7 @@ struct ErrorBannerModifier: ViewModifier { .padding(.top, MuxiTokens.Spacing.sm) } } - .animation(MuxiTokens.Motion.subtle, value: isPresented) + .muxiAnimation(\.subtle, value: isPresented) } } diff --git a/ios/Muxi/Views/Common/ReconnectingOverlay.swift b/ios/Muxi/Views/Common/ReconnectingOverlay.swift index 67c52e1..7985614 100644 --- a/ios/Muxi/Views/Common/ReconnectingOverlay.swift +++ b/ios/Muxi/Views/Common/ReconnectingOverlay.swift @@ -113,7 +113,7 @@ struct TmuxInstallGuideView: View { var body: some View { NavigationStack { ScrollView { - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: MuxiTokens.Spacing.xl) { headerSection installSection verifySection @@ -136,7 +136,7 @@ struct TmuxInstallGuideView: View { @ViewBuilder private var headerSection: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: MuxiTokens.Spacing.sm) { Label { switch reason { case .notInstalled: @@ -146,19 +146,19 @@ struct TmuxInstallGuideView: View { } } icon: { Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .foregroundStyle(MuxiTokens.Colors.warning) } .font(.headline) Text(descriptionText) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(MuxiTokens.Colors.textSecondary) } } @ViewBuilder private var installSection: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: MuxiTokens.Spacing.md) { Text("Install or Update tmux") .font(.subheadline.weight(.semibold)) @@ -171,7 +171,7 @@ struct TmuxInstallGuideView: View { @ViewBuilder private var verifySection: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: MuxiTokens.Spacing.md) { Text("Verify Installation") .font(.subheadline.weight(.semibold)) @@ -179,7 +179,7 @@ struct TmuxInstallGuideView: View { Text("Muxi requires tmux \(Self.minimumVersion) or later.") .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(MuxiTokens.Colors.textSecondary) } } @@ -196,18 +196,18 @@ struct TmuxInstallGuideView: View { @ViewBuilder private func commandBlock(label: String, command: String) -> some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: MuxiTokens.Spacing.xs) { Text(label) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(MuxiTokens.Colors.textSecondary) Text(command) .font(.system(.caption, design: .monospaced)) - .padding(10) + .padding(MuxiTokens.Spacing.sm) .frame(maxWidth: .infinity, alignment: .leading) .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(.systemGray6)) + RoundedRectangle(cornerRadius: MuxiTokens.Radius.sm, style: .continuous) + .fill(MuxiTokens.Colors.surfaceRaised) ) .textSelection(.enabled) } @@ -218,7 +218,7 @@ struct TmuxInstallGuideView: View { #Preview("Reconnecting Overlay") { ZStack { - Color(.systemBackground) + MuxiTokens.Colors.surfaceDefault .ignoresSafeArea() Text("Terminal Content Behind Overlay") diff --git a/ios/Muxi/Views/Settings/ThemeSettingsView.swift b/ios/Muxi/Views/Settings/ThemeSettingsView.swift index b546ec3..2ce04c4 100644 --- a/ios/Muxi/Views/Settings/ThemeSettingsView.swift +++ b/ios/Muxi/Views/Settings/ThemeSettingsView.swift @@ -15,7 +15,7 @@ struct ThemeSettingsView: View { VStack(alignment: .leading, spacing: MuxiTokens.Spacing.xs) { Text(theme.name) .font(.body) - .foregroundStyle(.primary) + .foregroundStyle(MuxiTokens.Colors.textPrimary) // Color preview: show first 8 ANSI colors as swatches. HStack(spacing: 2) { diff --git a/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift b/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift index 58511d7..67b372f 100644 --- a/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift +++ b/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift @@ -56,7 +56,7 @@ struct ExtendedKeyboardView: View { .foregroundStyle(MuxiTokens.Colors.textPrimary) .frame(minWidth: 36, minHeight: 32) .background(MuxiTokens.Colors.accentMuted) - .cornerRadius(MuxiTokens.Radius.sm) + .clipShape(RoundedRectangle(cornerRadius: MuxiTokens.Radius.sm)) } .buttonStyle(.plain) .accessibilityLabel("Dismiss Keyboard") @@ -78,7 +78,7 @@ struct ExtendedKeyboardView: View { .foregroundStyle(MuxiTokens.Colors.textPrimary) .frame(minWidth: 36, minHeight: 32) .background(MuxiTokens.Colors.accentMuted) - .cornerRadius(MuxiTokens.Radius.sm) + .clipShape(RoundedRectangle(cornerRadius: MuxiTokens.Radius.sm)) } .buttonStyle(.plain) } @@ -93,7 +93,7 @@ struct ExtendedKeyboardView: View { .foregroundStyle(active ? theme.background.color : MuxiTokens.Colors.textPrimary) .frame(minWidth: 36, minHeight: 32) .background(active ? MuxiTokens.Colors.accentDefault : MuxiTokens.Colors.accentMuted) - .cornerRadius(MuxiTokens.Radius.sm) + .clipShape(RoundedRectangle(cornerRadius: MuxiTokens.Radius.sm)) } .buttonStyle(.plain) } diff --git a/ios/Muxi/Views/Terminal/PaneContainerView.swift b/ios/Muxi/Views/Terminal/PaneContainerView.swift index 6521ea8..1d9ae91 100644 --- a/ios/Muxi/Views/Terminal/PaneContainerView.swift +++ b/ios/Muxi/Views/Terminal/PaneContainerView.swift @@ -149,7 +149,7 @@ struct PaneContainerView: View { ? MuxiTokens.Colors.accentSubtle : Color.clear ) - .cornerRadius(MuxiTokens.Radius.sm) + .clipShape(RoundedRectangle(cornerRadius: MuxiTokens.Radius.sm)) } .buttonStyle(.plain) } From 5ec52a70af34efb2ec218d1721e1833f91390510 Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 02:43:29 +0900 Subject: [PATCH 13/14] feat: migrate ServerList and ServerEdit views to design tokens Replace hardcoded colors, fonts, and spacing in ServerRowView, ServerListView, and ServerEditView with MuxiTokens for consistent warm dark theming across all server management screens. Co-Authored-By: Claude Opus 4.6 --- ios/Muxi/Views/ServerEdit/ServerEditView.swift | 2 ++ ios/Muxi/Views/ServerList/ServerListView.swift | 5 ++++- ios/Muxi/Views/ServerList/ServerRowView.swift | 11 ++++++----- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/ios/Muxi/Views/ServerEdit/ServerEditView.swift b/ios/Muxi/Views/ServerEdit/ServerEditView.swift index 65f54c8..a4123d8 100644 --- a/ios/Muxi/Views/ServerEdit/ServerEditView.swift +++ b/ios/Muxi/Views/ServerEdit/ServerEditView.swift @@ -66,6 +66,8 @@ struct ServerEditView: View { } } .onAppear { loadServer() } + .scrollContentBackground(.hidden) + .background(MuxiTokens.Colors.surfaceBase) } } diff --git a/ios/Muxi/Views/ServerList/ServerListView.swift b/ios/Muxi/Views/ServerList/ServerListView.swift index e5fb888..d796582 100644 --- a/ios/Muxi/Views/ServerList/ServerListView.swift +++ b/ios/Muxi/Views/ServerList/ServerListView.swift @@ -21,6 +21,7 @@ struct ServerListView: View { } label: { ServerRowView(server: server) } + .listRowBackground(MuxiTokens.Colors.surfaceDefault) .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { deleteServer(server) @@ -32,10 +33,12 @@ struct ServerListView: View { } label: { Label("Edit", systemImage: "pencil") } - .tint(.orange) + .tint(MuxiTokens.Colors.warning) } } } + .scrollContentBackground(.hidden) + .background(MuxiTokens.Colors.surfaceBase) .navigationTitle("Servers") .toolbar { Button { diff --git a/ios/Muxi/Views/ServerList/ServerRowView.swift b/ios/Muxi/Views/ServerList/ServerRowView.swift index 5275960..2e09d96 100644 --- a/ios/Muxi/Views/ServerList/ServerRowView.swift +++ b/ios/Muxi/Views/ServerList/ServerRowView.swift @@ -4,13 +4,14 @@ struct ServerRowView: View { let server: Server var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: MuxiTokens.Spacing.xs) { Text(server.name) - .font(.headline) + .font(MuxiTokens.Typography.title) + .foregroundStyle(MuxiTokens.Colors.textPrimary) Text("\(server.username)@\(server.host):\(server.port)") - .font(.caption) - .foregroundStyle(.secondary) + .font(MuxiTokens.Typography.caption) + .foregroundStyle(MuxiTokens.Colors.textSecondary) } - .padding(.vertical, 4) + .padding(.vertical, MuxiTokens.Spacing.xs) } } From 81ce05584d576a2ba8fe26ae6bd42a87821a92f7 Mon Sep 17 00:00:00 2001 From: supenom94 Date: Tue, 3 Mar 2026 09:16:22 +0900 Subject: [PATCH 14/14] fix: KeychainError LocalizedError conformance and credential flow - Add LocalizedError to KeychainError for actionable error messages instead of opaque "KeychainError error 0" - Pass retrieved password directly to connectToServer instead of discarding and re-reading from Keychain - Pre-validate SSH key existence before connection attempt - Remove CODE_SIGNING_ALLOWED=NO from CLAUDE.md build command (blocks Keychain access via errSecMissingEntitlement -34018) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 3 +-- ios/Muxi/App/ContentView.swift | 25 +++++++++++++++++++++---- ios/Muxi/Services/KeychainService.swift | 15 ++++++++++++++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 77d838b..31c751b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,8 +10,7 @@ cd ios && xcodegen generate # Run iOS app + unit tests xcodebuild test -project ios/Muxi.xcodeproj -scheme Muxi \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' \ - CODE_SIGNING_ALLOWED=NO + -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' # Run core C library tests (via SPM) swift test --package-path ios/MuxiCore diff --git a/ios/Muxi/App/ContentView.swift b/ios/Muxi/App/ContentView.swift index 8953da8..19f6b78 100644 --- a/ios/Muxi/App/ContentView.swift +++ b/ios/Muxi/App/ContentView.swift @@ -202,9 +202,9 @@ struct ContentView: View { // Try Keychain first; fall back to password prompt if not saved. do { let keychain = KeychainService() - _ = try keychain.retrievePassword(account: server.id.uuidString) + let password = try keychain.retrievePassword(account: server.id.uuidString) // Password found in Keychain, connect directly. - connectToServer(server) + connectToServer(server, password: password) } catch { if case KeychainError.itemNotFound = error { // No password in Keychain, prompt the user. @@ -217,8 +217,25 @@ struct ContentView: View { } } } - case .key: - connectToServer(server) + case .key(let keyId): + // Verify SSH key exists before attempting connection. + do { + let keychain = KeychainService() + _ = try keychain.retrieveSSHKey(id: keyId) + connectToServer(server) + } catch { + if case KeychainError.itemNotFound = error { + withAnimation { + errorMessage = "SSH key not found. Import a key in Settings." + showErrorBanner = true + } + } else { + withAnimation { + errorMessage = "Keychain error: \(error.localizedDescription)" + showErrorBanner = true + } + } + } } } diff --git a/ios/Muxi/Services/KeychainService.swift b/ios/Muxi/Services/KeychainService.swift index 48003e0..6fe7561 100644 --- a/ios/Muxi/Services/KeychainService.swift +++ b/ios/Muxi/Services/KeychainService.swift @@ -4,11 +4,24 @@ import Security // MARK: - KeychainError /// Errors that can occur when interacting with the iOS Keychain. -enum KeychainError: Error { +enum KeychainError: Error, LocalizedError { case itemNotFound case duplicateItem case unexpectedStatus(OSStatus) case dataConversionFailed + + var errorDescription: String? { + switch self { + case .itemNotFound: + return "No credentials found in Keychain" + case .duplicateItem: + return "Duplicate Keychain item" + case .unexpectedStatus(let status): + return "Keychain error (OSStatus \(status))" + case .dataConversionFailed: + return "Failed to decode Keychain data" + } + } } // MARK: - KeychainService