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/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 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 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/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]) } diff --git a/ios/Muxi/DesignSystem/MuxiTokens.swift b/ios/Muxi/DesignSystem/MuxiTokens.swift new file mode 100644 index 0000000..122f962 --- /dev/null +++ b/ios/Muxi/DesignSystem/MuxiTokens.swift @@ -0,0 +1,131 @@ +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: - 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 { + /// 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/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 diff --git a/ios/Muxi/Views/Common/ErrorBannerView.swift b/ios/Muxi/Views/Common/ErrorBannerView.swift index 3ca2aee..f33af23 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,8 +56,8 @@ struct ErrorBannerView: View { var onRetry: (() -> Void)? var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: MuxiTokens.Spacing.sm) { + HStack(alignment: .top, spacing: MuxiTokens.Spacing.md) { // Leading icon Image(systemName: style.icon) .foregroundStyle(style.color) @@ -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") @@ -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)") @@ -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,17 +133,17 @@ struct ErrorBannerModifier: ViewModifier { message: message, style: style, onDismiss: { - withAnimation(.easeInOut(duration: 0.25)) { + withAnimation(MuxiTokens.Motion.resolved(reduceMotion: reduceMotion).subtle) { isPresented = false } onDismiss?() }, onRetry: onRetry ) - .padding(.top, 8) + .padding(.top, MuxiTokens.Spacing.sm) } } - .animation(.easeInOut(duration: 0.25), value: isPresented) + .muxiAnimation(\.subtle, value: isPresented) } } diff --git a/ios/Muxi/Views/Common/ReconnectingOverlay.swift b/ios/Muxi/Views/Common/ReconnectingOverlay.swift index 36d2dc8..7985614 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) @@ -114,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 @@ -137,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: @@ -147,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)) @@ -172,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)) @@ -180,7 +179,7 @@ struct TmuxInstallGuideView: View { Text("Muxi requires tmux \(Self.minimumVersion) or later.") .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(MuxiTokens.Colors.textSecondary) } } @@ -197,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) } @@ -219,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/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/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) } } diff --git a/ios/Muxi/Views/Settings/ThemeSettingsView.swift b/ios/Muxi/Views/Settings/ThemeSettingsView.swift index a3ba3d0..2ce04c4 100644 --- a/ios/Muxi/Views/Settings/ThemeSettingsView.swift +++ b/ios/Muxi/Views/Settings/ThemeSettingsView.swift @@ -12,10 +12,10 @@ 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) + .foregroundStyle(MuxiTokens.Colors.textPrimary) // Color preview: show first 8 ANSI colors as swatches. HStack(spacing: 2) { @@ -31,7 +31,7 @@ struct ThemeSettingsView: View { if theme.id == themeManager.currentTheme.id { Image(systemName: "checkmark") - .foregroundStyle(.blue) + .foregroundStyle(MuxiTokens.Colors.accentDefault) } } } diff --git a/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift b/ios/Muxi/Views/Terminal/ExtendedKeyboardView.swift index 317e896..67b372f 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) } @@ -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) + .clipShape(RoundedRectangle(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) + .clipShape(RoundedRectangle(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) + .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 26c3497..1d9ae91 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) + .clipShape(RoundedRectangle(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.. 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 — 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() { + _ = MuxiTokens.Motion.appear + _ = MuxiTokens.Motion.tap + _ = MuxiTokens.Motion.transition + _ = MuxiTokens.Motion.subtle + } + + @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 + } +} 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